import path from 'path'; import type { FastifyInstance } from 'fastify'; import { eq, and, inArray } from 'drizzle-orm'; import { getDb, getRawDb, schema } from '../db/index.js'; import { authenticate } from '../utils/auth.js'; import { recordAuditEvent } from '../utils/auditLog.js'; import { generateSnowflake } from '../utils/snowflake.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'; import { deleteAttachmentFiles, deleteUploadFile, deleteAttachmentByFilename } from '../utils/fileCleanup.js'; import { resizeProfileImage } from '../utils/thumbnail.js'; import { config } from '../config.js'; import type { CreateSpaceRequest, UpdateSpaceRequest, JoinSpaceRequest, UpdateMemberRequest, Space, Channel, ChannelCategory, MemberWithUser, SpaceWithChannelsAndMembers, Role, } from '@backspace/shared'; import { AVATAR_COLORS } from '@backspace/shared'; import { sanitizeUser } from '../utils/sanitize.js'; import { checkVoicePermissions } from '../ws/events.js'; import { getLocalInviteSnapshot } from '../utils/spaceInviteSnapshot.js'; function rowToSpace(row: typeof schema.spaces.$inferSelect): Space { return { id: row.id, name: row.name, icon: row.icon, banner: row.banner ?? null, avatarColor: (row.avatarColor as Space['avatarColor']) ?? null, ownerId: row.ownerId, inviteCode: row.inviteCode, visibility: (row.visibility ?? 'private') as Space['visibility'], description: row.description ?? null, createdAt: row.createdAt, }; } function rowToChannel(row: typeof schema.channels.$inferSelect): Channel { return { id: row.id, spaceId: row.spaceId, name: row.name, type: row.type as Channel['type'], topic: row.topic, position: row.position ?? 0, categoryId: row.categoryId ?? null, createdAt: row.createdAt, }; } function generateInviteCode(): string { return crypto.randomBytes(4).toString('hex'); } export async function spaceRoutes(app: FastifyInstance): Promise { // POST /api/spaces - Create a new server app.post<{ Body: CreateSpaceRequest }>('/api/spaces', { preHandler: authenticate, }, async (request, reply) => { const { name, icon, banner, avatarColor, visibility, description } = request.body; if (!name || typeof name !== 'string') { return reply.code(400).send({ error: 'Space name is required', statusCode: 400 }); } const trimmedName = name.trim(); if (trimmedName.length < 1 || trimmedName.length > 100) { return reply.code(400).send({ error: 'Space name must be between 1 and 100 characters', statusCode: 400 }); } // Validate visibility const validVisibilities = ['public', 'request', 'private']; const safeVisibility = visibility && validVisibilities.includes(visibility) ? visibility : 'private'; // Validate description const safeDescription = description ? description.trim().slice(0, 200) || null : null; // Validate avatarColor — assign random if not provided const safeAvatarColor = avatarColor && (AVATAR_COLORS as readonly string[]).includes(avatarColor) ? avatarColor : AVATAR_COLORS[Math.floor(Math.random() * AVATAR_COLORS.length)]; const db = getDb(); const spaceId = generateSnowflake(); const textCategoryId = generateSnowflake(); const voiceCategoryId = generateSnowflake(); const channelId = generateSnowflake(); const voiceChannelId = generateSnowflake(); const now = Date.now(); const inviteCode = generateInviteCode(); // Create server, owner membership, default categories + channels, and @everyone role atomically db.transaction((tx) => { tx.insert(schema.spaces).values({ id: spaceId, name: trimmedName, icon: icon ?? null, banner: banner ?? null, avatarColor: safeAvatarColor, ownerId: request.userId, inviteCode, visibility: safeVisibility, description: safeDescription, createdAt: now, }).run(); tx.insert(schema.spaceMembers).values({ spaceId, userId: request.userId, joinedAt: now, }).run(); // Default categories tx.insert(schema.channelCategories).values({ id: textCategoryId, spaceId, name: 'text-channels', position: 0, createdAt: now, }).run(); tx.insert(schema.channelCategories).values({ id: voiceCategoryId, spaceId, name: 'voice-channels', position: 1, createdAt: now, }).run(); // Default text channel in text-channels category tx.insert(schema.channels).values({ id: channelId, spaceId, name: 'general', type: 'text', position: 0, categoryId: textCategoryId, createdAt: now, }).run(); // Default voice channel in voice-channels category tx.insert(schema.channels).values({ id: voiceChannelId, spaceId, name: 'voice', type: 'voice', position: 0, categoryId: voiceCategoryId, createdAt: now, }).run(); // Auto-create @everyone role (id = spaceId) tx.insert(schema.roles).values({ id: spaceId, spaceId, name: '@everyone', color: '#b9bbbe', position: 0, permissions: permissionsToString(DEFAULT_EVERYONE_PERMISSIONS), createdAt: now, }).run(); }); const server = db.select().from(schema.spaces).where(eq(schema.spaces.id, spaceId)).get(); if (!server) { return reply.code(500).send({ error: 'Failed to create space', statusCode: 500 }); } // Register the creator in connectionManager so they receive WS broadcasts for this space connectionManager.addUserSpace(request.userId, spaceId); // Clean up attachment records for icon/banner — reference is now in spaces table if (icon && typeof icon === 'string' && icon.includes('/api/uploads/')) { deleteAttachmentByFilename(icon); } if (banner && typeof banner === 'string' && banner.includes('/api/uploads/')) { deleteAttachmentByFilename(banner); } // Resize profile images to optimal dimensions if (icon && typeof icon === 'string' && !icon.startsWith('http')) { const filePath = path.join(config.uploadDir, path.basename(icon)); await resizeProfileImage(filePath, 'icon'); } if (banner && typeof banner === 'string' && !banner.startsWith('http')) { const filePath = path.join(config.uploadDir, path.basename(banner)); await resizeProfileImage(filePath, 'banner'); } return reply.code(201).send(rowToSpace(server)); }); // GET /api/spaces - List user's servers app.get('/api/spaces', { preHandler: authenticate, }, async (request, reply) => { const db = getDb(); const memberships = db.select() .from(schema.spaceMembers) .where(eq(schema.spaceMembers.userId, request.userId)) .all(); if (memberships.length === 0) { return reply.code(200).send([]); } const spaceIds = memberships.map(m => m.spaceId); const servers = db.select() .from(schema.spaces) .where(inArray(schema.spaces.id, spaceIds)) .all(); return reply.code(200).send(servers.map(rowToSpace)); }); // GET /api/spaces/:id - Get server detail with channels and members app.get<{ Params: { id: string } }>('/api/spaces/:id', { preHandler: authenticate, }, async (request, reply) => { const { id } = request.params; const db = getDb(); const server = db.select().from(schema.spaces).where(eq(schema.spaces.id, id)).get(); if (!server) { return reply.code(404).send({ error: 'Space not found', statusCode: 404 }); } if (!isMember(id, request.userId)) { return reply.code(403).send({ error: 'You are not a member of this space', statusCode: 403 }); } const channels = db.select() .from(schema.channels) .where(eq(schema.channels.spaceId, id)) .all(); const roles = db.select() .from(schema.roles) .where(eq(schema.roles.spaceId, id)) .orderBy(schema.roles.position) .all(); const memberRows = db.select() .from(schema.spaceMembers) .where(eq(schema.spaceMembers.spaceId, id)) .all(); const memberUserIds = memberRows.map(m => m.userId); const users = memberUserIds.length > 0 ? db.select().from(schema.users).where(inArray(schema.users.id, memberUserIds)).all() : []; const userMap = new Map(users.map(u => [u.id, u])); const memberRoleRows = db.select() .from(schema.memberRoles) .where(eq(schema.memberRoles.spaceId, id)) .all(); const members: MemberWithUser[] = memberRows .map(m => { const user = userMap.get(m.userId); if (!user) return null; const assignedRoleIds = memberRoleRows .filter(mr => mr.userId === m.userId) .map(mr => mr.roleId); const memberRoles = roles .filter(r => assignedRoleIds.includes(r.id)) .map(r => ({ id: r.id, spaceId: r.spaceId, name: r.name, color: r.color ?? '#b9bbbe', position: r.position ?? 0, createdAt: r.createdAt, })); return { spaceId: m.spaceId, userId: m.userId, nickname: m.nickname, joinedAt: m.joinedAt, user: sanitizeUser(user), roles: memberRoles, }; }) .filter((m): m is MemberWithUser => m !== null); // Fetch categories for this space const categoryRows = db.select() .from(schema.channelCategories) .where(eq(schema.channelCategories.spaceId, id)) .all(); // Batch-fetch category overrides for @everyone to determine isPrivate const catEveryoneOverrides = db.select().from(schema.categoryOverrides) .where(and( eq(schema.categoryOverrides.targetType, 'role'), eq(schema.categoryOverrides.targetId, id), )) .all(); const privateCategoryIds = new Set(); for (const o of catEveryoneOverrides) { const denyBits = BigInt(o.deny || '0'); if ((denyBits & PermissionBits.VIEW_CHANNEL) !== 0n) { privateCategoryIds.add(o.categoryId); } } const categories: ChannelCategory[] = categoryRows.map(c => ({ id: c.id, spaceId: c.spaceId, name: c.name, position: c.position ?? 0, isPrivate: privateCategoryIds.has(c.id), createdAt: c.createdAt, })); // Compute space-level permissions for the requesting user const spacePerms = computePermissions(request.userId, id); // Batch-fetch all channel overrides for @everyone (role = spaceId) to determine isPrivate const everyoneOverrides = db.select().from(schema.channelOverrides) .where(and( eq(schema.channelOverrides.targetType, 'role'), eq(schema.channelOverrides.targetId, id), )) .all(); const privateChannelIds = new Set(); for (const o of everyoneOverrides) { const denyBits = BigInt(o.deny || '0'); if ((denyBits & PermissionBits.VIEW_CHANNEL) !== 0n) { privateChannelIds.add(o.channelId); } } // Filter channels by VIEW_CHANNEL permission and attach per-channel myPermissions const visibleChannels: (Channel & { isPrivate: boolean; myPermissions: string })[] = []; for (const ch of channels) { const perms = computePermissions(request.userId, id, ch.id); if ((perms & PermissionBits.VIEW_CHANNEL) !== 0n) { visibleChannels.push({ ...rowToChannel(ch), isPrivate: privateChannelIds.has(ch.id), myPermissions: permissionsToString(perms), }); } } const canManageRoles = (spacePerms & PermissionBits.MANAGE_ROLES) !== 0n; const result: SpaceWithChannelsAndMembers = { ...rowToSpace(server), channels: visibleChannels, categories, members, roles: roles.map(r => ({ id: r.id, spaceId: r.spaceId, name: r.name, color: r.color ?? '#b9bbbe', position: r.position ?? 0, permissions: canManageRoles ? (r.permissions ?? '0') : undefined, createdAt: r.createdAt, })), myPermissions: permissionsToString(spacePerms), }; return reply.code(200).send(result); }); // PATCH /api/spaces/:id - Update server (owner only) app.patch<{ Params: { id: string }; Body: UpdateSpaceRequest }>('/api/spaces/:id', { preHandler: authenticate, }, async (request, reply) => { const { id } = request.params; const { name, icon, banner, avatarColor, visibility, description } = request.body; const db = getDb(); const server = db.select().from(schema.spaces).where(eq(schema.spaces.id, id)).get(); if (!server) { return reply.code(404).send({ error: 'Space not found', statusCode: 404 }); } if (!hasPermission(request.userId, id, PermissionBits.MANAGE_SPACE)) { return reply.code(403).send({ error: 'Missing MANAGE_SPACE permission', statusCode: 403 }); } const updates: Partial = {}; if (name !== undefined) { const trimmedName = name.trim(); if (trimmedName.length < 1 || trimmedName.length > 100) { return reply.code(400).send({ error: 'Space name must be between 1 and 100 characters', statusCode: 400 }); } updates.name = trimmedName; } // Track old files for cleanup after update const oldIcon = server.icon; const oldBanner = server.banner; if (icon !== undefined) { updates.icon = icon || null; } if (banner !== undefined) { updates.banner = banner || null; } if (avatarColor !== undefined) { if (avatarColor === '') { updates.avatarColor = null; } else if ((AVATAR_COLORS as readonly string[]).includes(avatarColor)) { updates.avatarColor = avatarColor; } else { return reply.code(400).send({ error: 'Invalid avatar color', statusCode: 400 }); } } if (visibility !== undefined) { const validVisibilities = ['public', 'request', 'private']; if (!validVisibilities.includes(visibility)) { return reply.code(400).send({ error: 'Visibility must be "public", "request", or "private"', statusCode: 400 }); } updates.visibility = visibility; } if (description !== undefined) { const trimmed = description.trim().slice(0, 200); updates.description = trimmed || null; } if (Object.keys(updates).length === 0) { return reply.code(400).send({ error: 'No fields to update', statusCode: 400 }); } db.update(schema.spaces).set(updates).where(eq(schema.spaces.id, id)).run(); // Clean up old icon/banner files that were replaced if (icon !== undefined && oldIcon && oldIcon !== (icon || null) && !oldIcon.startsWith('http')) { deleteUploadFile(oldIcon); deleteAttachmentByFilename(oldIcon); } if (banner !== undefined && oldBanner && oldBanner !== (banner || null) && !oldBanner.startsWith('http')) { deleteUploadFile(oldBanner); deleteAttachmentByFilename(oldBanner); } // Clean up attachment records for newly-set profile images — the reference // now lives in the spaces table, so the attachment record is unnecessary if (icon && typeof icon === 'string' && icon.includes('/api/uploads/')) { deleteAttachmentByFilename(icon); } if (banner && typeof banner === 'string' && banner.includes('/api/uploads/')) { deleteAttachmentByFilename(banner); } // Resize profile images to optimal dimensions if (icon && typeof icon === 'string' && !icon.startsWith('http')) { const filePath = path.join(config.uploadDir, path.basename(icon)); await resizeProfileImage(filePath, 'icon'); } if (banner && typeof banner === 'string' && !banner.startsWith('http')) { const filePath = path.join(config.uploadDir, path.basename(banner)); await resizeProfileImage(filePath, 'banner'); } const updated = db.select().from(schema.spaces).where(eq(schema.spaces.id, id)).get(); if (!updated) { return reply.code(500).send({ error: 'Failed to update space', statusCode: 500 }); } const spaceData = rowToSpace(updated); // Broadcast space_updated to all space members connectionManager.sendToSpace(id, { type: 'space_updated', space: spaceData, }); recordAuditEvent({ spaceId: id, actorId: request.userId, action: 'space.update', targetType: 'space', targetId: id, metadata: { fields: Object.keys(updates) }, }); return reply.code(200).send(spaceData); }); // DELETE /api/spaces/:id - Delete server (owner only) app.delete<{ Params: { id: string } }>('/api/spaces/:id', { preHandler: authenticate, }, async (request, reply) => { const { id } = request.params; const db = getDb(); const server = db.select().from(schema.spaces).where(eq(schema.spaces.id, id)).get(); if (!server) { return reply.code(404).send({ error: 'Space not found', statusCode: 404 }); } if (!isSpaceOwner(id, request.userId)) { return reply.code(403).send({ error: 'Only the space owner can delete the space', statusCode: 403 }); } // Collect all attachment files before cascade-deleting DB records const channelIds = db.select({ id: schema.channels.id }) .from(schema.channels).where(eq(schema.channels.spaceId, id)).all().map(c => c.id); let attachmentRows: { filename: string }[] = []; if (channelIds.length > 0) { const messageIds = db.select({ id: schema.messages.id }) .from(schema.messages).where(inArray(schema.messages.channelId, channelIds)).all().map(m => m.id); if (messageIds.length > 0) { attachmentRows = db.select({ filename: schema.attachments.filename }) .from(schema.attachments).where(inArray(schema.attachments.messageId, messageIds)).all(); } } // Capture space icon/banner before deletion const spaceIcon = server.icon; const spaceBanner = server.banner; // Delete all channels (messages cascade), members, folder refs, read states, then space atomically db.transaction((tx) => { // Clean up read_states for all channels in this space (no FK cascade — channelId is plain text) if (channelIds.length > 0) { tx.delete(schema.readStates).where(inArray(schema.readStates.channelId, channelIds)).run(); } tx.delete(schema.channels).where(eq(schema.channels.spaceId, id)).run(); tx.delete(schema.spaceMembers).where(eq(schema.spaceMembers.spaceId, id)).run(); tx.delete(schema.spaceFolderMembers).where(eq(schema.spaceFolderMembers.spaceId, id)).run(); tx.delete(schema.spaces).where(eq(schema.spaces.id, id)).run(); }); // Clean up all attachment files from disk deleteAttachmentFiles(attachmentRows); // Clean up space icon/banner files if (spaceIcon && !spaceIcon.startsWith('http')) deleteUploadFile(spaceIcon); if (spaceBanner && !spaceBanner.startsWith('http')) deleteUploadFile(spaceBanner); return reply.code(200).send({ success: true }); }); // POST /api/spaces/:id/invite - Generate invite code (admin+) app.post<{ Params: { id: string } }>('/api/spaces/:id/invite', { preHandler: authenticate, }, async (request, reply) => { const { id } = request.params; const db = getDb(); const server = db.select().from(schema.spaces).where(eq(schema.spaces.id, id)).get(); if (!server) { return reply.code(404).send({ error: 'Space not found', statusCode: 404 }); } if (!hasPermission(request.userId, id, PermissionBits.CREATE_INVITE)) { // Owners and instance admins always pass hasPermission, so anyone who lands // here is either a non-member or a member without CREATE_INVITE. Give the // non-member a clearer "go join first" message instead of a permission error. if (!isMember(id, request.userId)) { return reply.code(403).send({ error: 'Space membership required', statusCode: 403 }); } return reply.code(403).send({ error: 'Missing CREATE_INVITE permission', statusCode: 403 }); } // Request-only spaces are approval-gated and never joinable by invite code // (see the join endpoints), so they have no usable invite links. Refuse to // hand one out rather than mint a code that would dead-end at the join guard. if (server.visibility === 'request') { return reply.code(403).send({ error: 'Request-only spaces do not use invite links; entry is by join request', statusCode: 403 }); } // Return existing invite code if one exists, otherwise generate a new one if (server.inviteCode) { return reply.code(200).send({ inviteCode: server.inviteCode }); } const inviteCode = generateInviteCode(); db.update(schema.spaces).set({ inviteCode }).where(eq(schema.spaces.id, id)).run(); return reply.code(200).send({ inviteCode }); }); // POST /api/spaces/:id/join - Join server by invite code app.post<{ Params: { id: string }; Body: JoinSpaceRequest }>('/api/spaces/:id/join', { preHandler: authenticate, }, async (request, reply) => { const { id } = request.params; const { inviteCode } = request.body; if (!inviteCode || typeof inviteCode !== 'string') { return reply.code(400).send({ error: 'Invite code is required', statusCode: 400 }); } const db = getDb(); const server = db.select().from(schema.spaces).where(eq(schema.spaces.id, id)).get(); if (!server) { return reply.code(404).send({ error: 'Space not found', statusCode: 404 }); } if (server.inviteCode !== inviteCode) { 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 }); } // Request-only spaces are gated by manager approval: entry must go through // POST /request-join + approval, never a bearer invite code. (Private spaces // remain invite-joinable — that is their only entry path; public too.) if (server.visibility === 'request') { return reply.code(403).send({ error: 'This space requires an approved join request', statusCode: 403 }); } const now = Date.now(); db.insert(schema.spaceMembers).values({ spaceId: id, userId: request.userId, joinedAt: now, }).run(); // Register the user in connectionManager so they receive WS broadcasts for this server connectionManager.addUserSpace(request.userId, id); // Broadcast member_joined to existing server members const joiningUser = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get(); if (joiningUser) { const memberPayload: MemberWithUser = { spaceId: id, userId: request.userId, nickname: null, joinedAt: now, user: sanitizeUser(joiningUser), roles: [], }; connectionManager.sendToSpace(id, { type: 'member_joined', spaceId: id, member: memberPayload, }); } return reply.code(200).send(rowToSpace(server)); }); // POST /api/spaces/join - Join server by invite code (no server ID needed) app.post<{ Body: JoinSpaceRequest }>('/api/spaces/join', { preHandler: authenticate, }, async (request, reply) => { const { inviteCode } = request.body; if (!inviteCode || typeof inviteCode !== 'string') { return reply.code(400).send({ error: 'Invite code is required', statusCode: 400 }); } const db = getDb(); const server = db.select().from(schema.spaces).where(eq(schema.spaces.inviteCode, inviteCode)).get(); if (!server) { 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 }); } // Request-only spaces are gated by manager approval (see POST /:id/join). if (server.visibility === 'request') { return reply.code(403).send({ error: 'This space requires an approved join request', statusCode: 403 }); } const now = Date.now(); db.insert(schema.spaceMembers).values({ spaceId: server.id, userId: request.userId, joinedAt: now, }).run(); // Register the user in connectionManager so they receive WS broadcasts for this server connectionManager.addUserSpace(request.userId, server.id); // Broadcast member_joined to existing server members const joiningUser = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get(); if (joiningUser) { const memberPayload: MemberWithUser = { spaceId: server.id, userId: request.userId, nickname: null, joinedAt: now, user: sanitizeUser(joiningUser), roles: [], }; connectionManager.sendToSpace(server.id, { type: 'member_joined', spaceId: server.id, member: memberPayload, }); } return reply.code(200).send(rowToSpace(server)); }); // GET /api/spaces/:id/members - List server members app.get<{ Params: { id: string } }>('/api/spaces/:id/members', { preHandler: authenticate, }, async (request, reply) => { const { id } = request.params; const db = getDb(); const server = db.select().from(schema.spaces).where(eq(schema.spaces.id, id)).get(); if (!server) { return reply.code(404).send({ error: 'Space not found', statusCode: 404 }); } if (!isMember(id, request.userId)) { return reply.code(403).send({ error: 'You are not a member of this space', statusCode: 403 }); } const memberRows = db.select() .from(schema.spaceMembers) .where(eq(schema.spaceMembers.spaceId, id)) .all(); const memberUserIds = memberRows.map(m => m.userId); const users = memberUserIds.length > 0 ? db.select().from(schema.users).where(inArray(schema.users.id, memberUserIds)).all() : []; const userMap = new Map(users.map(u => [u.id, u])); const roles = db.select() .from(schema.roles) .where(eq(schema.roles.spaceId, id)) .orderBy(schema.roles.position) .all(); const memberRoleRows = db.select() .from(schema.memberRoles) .where(eq(schema.memberRoles.spaceId, id)) .all(); const members: MemberWithUser[] = memberRows .map(m => { const user = userMap.get(m.userId); if (!user) return null; const assignedRoleIds = memberRoleRows .filter(mr => mr.userId === m.userId) .map(mr => mr.roleId); const assignedRoles = roles .filter(r => assignedRoleIds.includes(r.id)) .map(r => ({ id: r.id, spaceId: r.spaceId, name: r.name, color: r.color ?? '#b9bbbe', position: r.position ?? 0, createdAt: r.createdAt, })); return { spaceId: m.spaceId, userId: m.userId, nickname: m.nickname, joinedAt: m.joinedAt, user: sanitizeUser(user), roles: assignedRoles, }; }) .filter((m): m is MemberWithUser => m !== null); return reply.code(200).send(members); }); // PATCH /api/spaces/:id/members/:uid - Update member roles app.patch<{ Params: { id: string; uid: string }; Body: UpdateMemberRequest }>('/api/spaces/:id/members/:uid', { preHandler: authenticate, }, async (request, reply) => { const { id, uid } = request.params; const { roleIds } = request.body; const db = getDb(); const server = db.select().from(schema.spaces).where(eq(schema.spaces.id, id)).get(); if (!server) { return reply.code(404).send({ error: 'Space not found', statusCode: 404 }); } if (!hasPermission(request.userId, id, PermissionBits.MANAGE_ROLES)) { return reply.code(403).send({ error: 'Missing MANAGE_ROLES permission', statusCode: 403 }); } if (uid === request.userId) { return reply.code(400).send({ error: 'You cannot change your own roles', statusCode: 400 }); } if (!Array.isArray(roleIds)) { return reply.code(400).send({ error: 'roleIds must be an array of role IDs', statusCode: 400 }); } // Cannot modify the server owner's roles unless you are the owner if (isSpaceOwner(id, uid) && !isSpaceOwner(id, request.userId)) { return reply.code(403).send({ error: 'Only the space owner can modify their own roles', statusCode: 403 }); } const member = db.select() .from(schema.spaceMembers) .where(and( eq(schema.spaceMembers.spaceId, id), eq(schema.spaceMembers.userId, uid), )) .get(); if (!member) { return reply.code(404).send({ error: 'Member not found', statusCode: 404 }); } // Validate all roleIds belong to this server and are not @everyone if (roleIds.length > 0) { const spaceRoles = db.select() .from(schema.roles) .where(eq(schema.roles.spaceId, id)) .all(); const spaceRoleIds = new Set(spaceRoles.map(r => r.id)); for (const roleId of roleIds) { if (!spaceRoleIds.has(roleId)) { return reply.code(400).send({ error: `Role ${roleId} does not belong to this space`, statusCode: 400 }); } if (roleId === id) { return reply.code(400).send({ error: '@everyone role is implicit and cannot be assigned', statusCode: 400 }); } } } // Atomically replace member's role assignments db.transaction((tx) => { // Remove all existing role assignments for this member in this server tx.delete(schema.memberRoles) .where(and( eq(schema.memberRoles.spaceId, id), eq(schema.memberRoles.userId, uid), )) .run(); // Insert new role assignments for (const roleId of roleIds) { tx.insert(schema.memberRoles).values({ spaceId: id, userId: uid, roleId, }).run(); } }); // Force target user's client to re-sync with their new permissions connectionManager.pushReadyPayload(uid); checkVoicePermissions(id); // Build response with populated roles const updatedMember = db.select() .from(schema.spaceMembers) .where(and( eq(schema.spaceMembers.spaceId, id), eq(schema.spaceMembers.userId, uid), )) .get(); if (!updatedMember) { return reply.code(500).send({ error: 'Failed to update member', statusCode: 500 }); } const user = db.select().from(schema.users).where(eq(schema.users.id, uid)).get(); if (!user) { return reply.code(500).send({ error: 'User not found', statusCode: 500 }); } const updatedRoleRows = db.select() .from(schema.memberRoles) .where(and( eq(schema.memberRoles.spaceId, id), eq(schema.memberRoles.userId, uid), )) .all(); const updatedRoleIds = updatedRoleRows.map(r => r.roleId); const allRoles = db.select() .from(schema.roles) .where(eq(schema.roles.spaceId, id)) .orderBy(schema.roles.position) .all(); const memberRoles = allRoles .filter(r => updatedRoleIds.includes(r.id)) .map(r => ({ id: r.id, spaceId: r.spaceId, name: r.name, color: r.color ?? '#b9bbbe', position: r.position ?? 0, createdAt: r.createdAt, })); const result: MemberWithUser = { spaceId: updatedMember.spaceId, userId: updatedMember.userId, nickname: updatedMember.nickname, joinedAt: updatedMember.joinedAt, user: sanitizeUser(user), roles: memberRoles, }; return reply.code(200).send(result); }); // DELETE /api/spaces/:id/members/:uid - Kick member (owner) or leave (self) app.delete<{ Params: { id: string; uid: string } }>('/api/spaces/:id/members/:uid', { preHandler: authenticate, }, async (request, reply) => { const { id, uid } = request.params; const db = getDb(); const server = db.select().from(schema.spaces).where(eq(schema.spaces.id, id)).get(); if (!server) { return reply.code(404).send({ error: 'Space not found', statusCode: 404 }); } const isSelf = uid === request.userId; const isOwnerUser = isSpaceOwner(id, request.userId); const canKick = hasPermission(request.userId, id, PermissionBits.KICK_MEMBERS); if (!isSelf && !canKick) { return reply.code(403).send({ error: 'Missing KICK_MEMBERS permission', statusCode: 403 }); } // Owner cannot leave their own server - they must delete it if (isSelf && isOwnerUser) { return reply.code(400).send({ error: 'Space owner cannot leave. Transfer ownership or delete the space.', statusCode: 400 }); } const member = db.select() .from(schema.spaceMembers) .where(and( eq(schema.spaceMembers.spaceId, id), eq(schema.spaceMembers.userId, uid), )) .get(); if (!member) { return reply.code(404).send({ error: 'Member not found', statusCode: 404 }); } // Cannot kick the owner if (isSpaceOwner(id, uid)) { return reply.code(400).send({ error: 'Cannot remove the space owner', statusCode: 400 }); } db.delete(schema.spaceMembers) .where(and( eq(schema.spaceMembers.spaceId, id), eq(schema.spaceMembers.userId, uid), )) .run(); // Clean up any voice restrictions for the removed member db.delete(schema.voiceRestrictions).where( and( eq(schema.voiceRestrictions.spaceId, id), eq(schema.voiceRestrictions.userId, uid), ) ).run(); // Clean up read_states for the departing user in this space's channels const spaceChannelIds = db.select({ id: schema.channels.id }) .from(schema.channels).where(eq(schema.channels.spaceId, id)).all().map(c => c.id); if (spaceChannelIds.length > 0) { db.delete(schema.readStates).where(and( eq(schema.readStates.userId, uid), inArray(schema.readStates.channelId, spaceChannelIds), )).run(); } // Broadcast member_left event connectionManager.sendToSpace(id, { type: 'member_left', spaceId: id, userId: uid, }); recordAuditEvent({ spaceId: id, actorId: request.userId, // Leaving on your own is not the same event as being removed by someone // else, and a log that conflates the two misleads exactly when it matters. action: request.userId === uid ? 'member.leave' : 'member.kick', targetType: 'user', targetId: uid, }); return reply.code(200).send({ success: true }); }); // Role Management // POST /api/spaces/:id/roles - Create a new role app.post<{ Params: { id: string }; Body: { name: string; color?: string; permissions?: string } }>('/api/spaces/:id/roles', { preHandler: authenticate, }, async (request, reply) => { const { id } = request.params; const { name, color, permissions } = request.body; const db = getDb(); if (!hasPermission(request.userId, id, PermissionBits.MANAGE_ROLES)) { return reply.code(403).send({ error: 'Missing MANAGE_ROLES permission', statusCode: 403 }); } // Validate permissions string is a valid bigint if provided let permStr: string; if (permissions !== undefined && permissions !== null) { try { BigInt(permissions); permStr = permissions; } catch { return reply.code(400).send({ error: 'Invalid permissions value', statusCode: 400 }); } } else { // Default to @everyone baseline so new roles start functional permStr = permissionsToString(DEFAULT_EVERYONE_PERMISSIONS); } // Trim and validate name const roleName = (name || 'new role').trim() || 'new role'; // Check for case-insensitive duplicate name within the space const rawDb = getRawDb(); const duplicate = rawDb.prepare( 'SELECT id FROM roles WHERE space_id = ? AND name COLLATE NOCASE = ?' ).get(id, roleName); if (duplicate) { return reply.code(409).send({ error: 'A role with this name already exists', statusCode: 409 }); } const roleId = generateSnowflake(); db.insert(schema.roles).values({ id: roleId, spaceId: id, name: roleName, color: color || '#b9bbbe', position: 0, permissions: permStr, createdAt: Date.now(), }).run(); const role = db.select().from(schema.roles).where(eq(schema.roles.id, roleId)).get(); // Broadcast updated state to all space members const memberRows = db.select().from(schema.spaceMembers).where(eq(schema.spaceMembers.spaceId, id)).all(); for (const m of memberRows) { connectionManager.pushReadyPayload(m.userId); } checkVoicePermissions(id); recordAuditEvent({ spaceId: id, actorId: request.userId, action: 'role.create', targetType: 'role', // roleId is the value just inserted; `role` is a read-back the compiler // cannot prove returned a row. targetId: roleId, metadata: { name: role?.name ?? null }, }); return reply.code(201).send(role); }); // PATCH /api/spaces/:id/roles/:roleId - Update a role app.patch<{ Params: { id: string; roleId: string }; Body: { name?: string; color?: string; position?: number; permissions?: string } }>('/api/spaces/:id/roles/:roleId', { preHandler: authenticate, }, async (request, reply) => { const { id, roleId } = request.params; const { name, color, position, permissions } = request.body; const db = getDb(); if (!hasPermission(request.userId, id, PermissionBits.MANAGE_ROLES)) { return reply.code(403).send({ error: 'Missing MANAGE_ROLES permission', statusCode: 403 }); } const updates: Partial = {}; if (name !== undefined) { const trimmed = name.trim(); if (!trimmed) { return reply.code(400).send({ error: 'Role name cannot be empty', statusCode: 400 }); } // Check for case-insensitive duplicate name within the space const rawDb = getRawDb(); const duplicate = rawDb.prepare( 'SELECT id FROM roles WHERE space_id = ? AND name COLLATE NOCASE = ? AND id != ?' ).get(id, trimmed, roleId); if (duplicate) { return reply.code(409).send({ error: 'A role with this name already exists', statusCode: 409 }); } updates.name = trimmed; } if (color !== undefined) updates.color = color; if (position !== undefined) updates.position = position; if (permissions !== undefined) { try { BigInt(permissions); updates.permissions = permissions; } catch { return reply.code(400).send({ error: 'Invalid permissions value', statusCode: 400 }); } } if (Object.keys(updates).length === 0) { return reply.code(400).send({ error: 'No fields to update', statusCode: 400 }); } db.update(schema.roles).set(updates).where(and(eq(schema.roles.id, roleId), eq(schema.roles.spaceId, id))).run(); const updated = db.select().from(schema.roles).where(eq(schema.roles.id, roleId)).get(); // Broadcast updated state to all space members const memberRows = db.select().from(schema.spaceMembers).where(eq(schema.spaceMembers.spaceId, id)).all(); for (const m of memberRows) { connectionManager.pushReadyPayload(m.userId); } checkVoicePermissions(id); recordAuditEvent({ spaceId: id, actorId: request.userId, action: 'role.update', targetType: 'role', targetId: roleId, metadata: { name: updated?.name ?? null }, }); return reply.code(200).send(updated); }); // DELETE /api/spaces/:id/roles/:roleId - Delete a role app.delete<{ Params: { id: string; roleId: string } }>('/api/spaces/:id/roles/:roleId', { preHandler: authenticate, }, async (request, reply) => { const { id, roleId } = request.params; const db = getDb(); if (!hasPermission(request.userId, id, PermissionBits.MANAGE_ROLES)) { return reply.code(403).send({ error: 'Missing MANAGE_ROLES permission', statusCode: 403 }); } // Cannot delete @everyone role if (roleId === id) { return reply.code(400).send({ error: 'Cannot delete the @everyone role', statusCode: 400 }); } // Delete channel overrides referencing this role db.delete(schema.channelOverrides).where( and(eq(schema.channelOverrides.targetType, 'role'), eq(schema.channelOverrides.targetId, roleId)) ).run(); db.delete(schema.roles).where(and(eq(schema.roles.id, roleId), eq(schema.roles.spaceId, id))).run(); // Broadcast updated state to all space members const memberRows = db.select().from(schema.spaceMembers).where(eq(schema.spaceMembers.spaceId, id)).all(); for (const m of memberRows) { connectionManager.pushReadyPayload(m.userId); } checkVoicePermissions(id); return reply.code(200).send({ success: true }); }); // POST /api/spaces/:id/members/:uid/roles - Add role to member app.post<{ Params: { id: string; uid: string }; Body: { roleId: string } }>('/api/spaces/:id/members/:uid/roles', { preHandler: authenticate, }, async (request, reply) => { const { id, uid } = request.params; const { roleId } = request.body; const db = getDb(); if (!hasPermission(request.userId, id, PermissionBits.MANAGE_ROLES)) { return reply.code(403).send({ error: 'Missing MANAGE_ROLES permission', statusCode: 403 }); } db.insert(schema.memberRoles).values({ spaceId: id, userId: uid, roleId, }).run(); return reply.code(200).send({ success: true }); }); // DELETE /api/spaces/:id/members/:uid/roles/:roleId - Remove role from member app.delete<{ Params: { id: string; uid: string; roleId: string } }>('/api/spaces/:id/members/:uid/roles/:roleId', { preHandler: authenticate, }, async (request, reply) => { const { id, uid, roleId } = request.params; const db = getDb(); if (!hasPermission(request.userId, id, PermissionBits.MANAGE_ROLES)) { return reply.code(403).send({ error: 'Missing MANAGE_ROLES permission', statusCode: 403 }); } db.delete(schema.memberRoles).where(and( eq(schema.memberRoles.spaceId, id), eq(schema.memberRoles.userId, uid), eq(schema.memberRoles.roleId, roleId) )).run(); return reply.code(200).send({ success: true }); }); // PATCH /api/spaces/:id/transfer-ownership — Transfer space ownership app.patch<{ Params: { id: string }; Body: { newOwnerId: string } }>('/api/spaces/:id/transfer-ownership', { preHandler: authenticate, }, async (request, reply) => { const { id } = request.params; const { newOwnerId } = request.body; const db = getDb(); if (!newOwnerId || typeof newOwnerId !== 'string') { return reply.code(400).send({ error: 'newOwnerId is required', statusCode: 400 }); } const server = db.select().from(schema.spaces).where(eq(schema.spaces.id, id)).get(); if (!server) { return reply.code(404).send({ error: 'Space not found', statusCode: 404 }); } if (!isSpaceOwner(id, request.userId)) { return reply.code(403).send({ error: 'Only the space owner can transfer ownership', statusCode: 403 }); } if (newOwnerId === request.userId) { return reply.code(400).send({ error: 'You are already the owner', statusCode: 400 }); } // Verify new owner is a member if (!isMember(id, newOwnerId)) { return reply.code(400).send({ error: 'New owner must be a member of the space', statusCode: 400 }); } db.update(schema.spaces).set({ ownerId: newOwnerId }).where(eq(schema.spaces.id, id)).run(); const updated = db.select().from(schema.spaces).where(eq(schema.spaces.id, id)).get(); if (!updated) { return reply.code(500).send({ error: 'Failed to transfer ownership', statusCode: 500 }); } const spaceData = rowToSpace(updated); // Broadcast space_updated so all clients see the new owner connectionManager.sendToSpace(id, { type: 'space_updated', space: spaceData, }); recordAuditEvent({ spaceId: id, actorId: request.userId, action: 'space.transfer_ownership', targetType: 'user', targetId: newOwnerId, }); return reply.code(200).send(spaceData); }); // GET /api/spaces/invite/:code/preview — Public invite preview (no auth) app.get<{ Params: { code: string } }>('/api/spaces/invite/:code/preview', async (request, reply) => { const { code } = request.params; const snapshot = getLocalInviteSnapshot(code); if (!snapshot) { return reply.code(404).send({ error: 'Invalid invite code', statusCode: 404 }); } return reply.code(200).send(snapshot); }); // ─── 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].filter((id): id is string => id !== null))]; const users = allUserIds.length > 0 ? 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 = b.bannedBy ? userMap.get(b.bannedBy) : undefined; 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(); // Fetch channel IDs before the transaction for read_states cleanup const banChannelIds = db.select({ id: schema.channels.id }) .from(schema.channels).where(eq(schema.channels.spaceId, id)).all().map(c => c.id); 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(); // Clean up read_states for the banned user in this space's channels if (banChannelIds.length > 0) { tx.delete(schema.readStates).where(and( eq(schema.readStates.userId, targetId), inArray(schema.readStates.channelId, banChannelIds), )).run(); } // Clean up any voice restrictions for the banned member tx.delete(schema.voiceRestrictions).where(and( eq(schema.voiceRestrictions.spaceId, id), eq(schema.voiceRestrictions.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 }); }); }