Files
backspace/packages/server/src/routes/spaces.ts
T
Jannis Braun 44e9a4234c fix(server): local-fast-path for invite snapshot — skip HTTP self-reach
POST /api/dm/space-invite was hanging 5s and returning invite_invalid
for any local-space invite. fetchSpaceInviteSnapshot was being called
against our own public domain from inside the backspace container, which
fails (Docker NAT loopback) and aborts on timeout.

Add getLocalInviteSnapshot — reads the snapshot directly from the DB —
and branch in dm.ts so local invites bypass the HTTP roundtrip entirely.
Cross-instance invites still go through fetchSpaceInviteSnapshot with
its existing SSRF guard.

Also refactor the GET /api/spaces/invite/:code/preview handler to use
the same helper, keeping the snapshot shape in one place.

Tests assert fetchSpaceInviteSnapshot is NOT called for the local case
(critical regression guard) and that the cross-instance path still hits
the HTTP fetch.
2026-04-29 22:29:44 +02:00

1396 lines
47 KiB
TypeScript

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 { 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<void> {
// 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<string>();
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<string>();
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<typeof schema.spaces.$inferInsert> = {};
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,
});
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)) {
return reply.code(403).send({ error: 'Missing CREATE_INVITE permission', 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 });
}
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 });
}
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,
});
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);
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<typeof schema.roles.$inferInsert> = {};
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);
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,
});
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 });
});
}