Files
backspace/packages/server/src/routes/channels.ts
T
devsyncwrld bbb190cbda
OpenSSF Scorecard / Scorecard analysis (push) Waiting to run
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
feat(audit): append-only audit log for spaces
Records who changed what, and is the mechanism statistics will read — one
event table rather than two logs that drift apart.

The table is deliberately generic (action + target + JSON metadata) so a new
action needs no migration. Writes never throw: a kick must not fail because
its log entry could not be written, since the kick already happened.

Leaving is recorded as a different action from being removed. The same route
serves both, and a log that conflates them misleads exactly when it matters.

Actor is nullable with ON DELETE SET NULL: the event outlives the account, and
a log that vanished with its actor would be worthless. Reads are gated on
MANAGE_SPACE rather than a new permission bit, which would default to nobody
until every role was re-edited. Paging uses the snowflake id, stable even for
two events in the same millisecond, and an action this build does not know
still renders a row.
2026-08-31 13:22:52 -03:00

1016 lines
36 KiB
TypeScript

import type { FastifyInstance } from 'fastify';
import { eq, and, inArray } from 'drizzle-orm';
import { getDb, 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, hasPermission, getChannelSpaceId, PermissionBits, computePermissions } from '../utils/permissions.js';
import { permissionsToString } from '@backspace/shared/src/permissions.js';
import { connectionManager } from '../ws/handler.js';
import { checkVoicePermissions } from '../ws/events.js';
import { deleteAttachmentFiles } from '../utils/fileCleanup.js';
import type {
CreateChannelRequest,
UpdateChannelRequest,
Channel,
ChannelCategory,
} from '@backspace/shared';
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 rowToCategory(row: typeof schema.channelCategories.$inferSelect): ChannelCategory {
return {
id: row.id,
spaceId: row.spaceId,
name: row.name,
position: row.position ?? 0,
createdAt: row.createdAt,
};
}
/**
* Check if a channel is private by looking for a VIEW_CHANNEL deny on @everyone.
* The @everyone role ID equals the space ID.
*/
function isChannelPrivate(channelId: string, spaceId: string): boolean {
const db = getDb();
const override = db.select().from(schema.channelOverrides).where(
and(
eq(schema.channelOverrides.channelId, channelId),
eq(schema.channelOverrides.targetType, 'role'),
eq(schema.channelOverrides.targetId, spaceId),
)
).get();
if (!override) return false;
const denyBits = BigInt(override.deny || '0');
return (denyBits & PermissionBits.VIEW_CHANNEL) !== 0n;
}
/**
* After a channel override changes, notify each space member:
* - VIEW_CHANNEL holders receive channel_updated (with their myPermissions)
* - Non-viewers receive channel_deleted to remove the channel from their UI
*/
function broadcastOverrideChange(spaceId: string, channelId: string): void {
const db = getDb();
const channel = db.select().from(schema.channels).where(eq(schema.channels.id, channelId)).get();
if (!channel) return;
const channelData = rowToChannel(channel);
const priv = isChannelPrivate(channelId, spaceId);
for (const [userId, spaceIds] of connectionManager.getUserSpaceEntries()) {
if (!spaceIds.has(spaceId)) continue;
const perms = computePermissions(userId, spaceId, channelId);
if ((perms & PermissionBits.VIEW_CHANNEL) !== 0n) {
connectionManager.sendToUser(userId, {
type: 'channel_updated',
channel: { ...channelData, isPrivate: priv, myPermissions: permissionsToString(perms) },
spaceId,
});
} else {
connectionManager.sendToUser(userId, {
type: 'channel_deleted',
channelId,
spaceId,
});
}
}
}
/**
* Check if a category is private by looking for VIEW_CHANNEL deny on @everyone.
*/
function isCategoryPrivate(categoryId: string, spaceId: string): boolean {
const db = getDb();
const override = db.select().from(schema.categoryOverrides).where(
and(
eq(schema.categoryOverrides.categoryId, categoryId),
eq(schema.categoryOverrides.targetType, 'role'),
eq(schema.categoryOverrides.targetId, spaceId),
)
).get();
if (!override) return false;
const denyBits = BigInt(override.deny || '0');
return (denyBits & PermissionBits.VIEW_CHANNEL) !== 0n;
}
/**
* When a category's overrides change, re-evaluate visibility for all channels
* in that category and send channel_updated/channel_deleted per user.
* Also broadcasts category_updated with isPrivate for the lock icon.
*/
function broadcastCategoryOverrideChange(spaceId: string, categoryId: string): void {
const db = getDb();
const channelsInCategory = db.select().from(schema.channels)
.where(and(eq(schema.channels.spaceId, spaceId), eq(schema.channels.categoryId, categoryId)))
.all();
for (const ch of channelsInCategory) {
broadcastOverrideChange(spaceId, ch.id);
}
const category = db.select().from(schema.channelCategories)
.where(eq(schema.channelCategories.id, categoryId)).get();
if (category) {
const isPrivate = isCategoryPrivate(categoryId, spaceId);
connectionManager.sendToSpace(spaceId, {
type: 'category_updated',
category: { ...rowToCategory(category), isPrivate },
spaceId,
});
}
}
export async function channelRoutes(app: FastifyInstance): Promise<void> {
// GET /api/spaces/:id/channels - List channels in a space
app.get<{ Params: { id: string } }>('/api/spaces/:id/channels', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const db = getDb();
const space = db.select().from(schema.spaces).where(eq(schema.spaces.id, id)).get();
if (!space) {
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 allChannels = db.select()
.from(schema.channels)
.where(eq(schema.channels.spaceId, id))
.all();
// Filter by VIEW_CHANNEL permission per channel
const visibleChannels = allChannels.filter(ch => {
const perms = computePermissions(request.userId, id, ch.id);
return (perms & PermissionBits.VIEW_CHANNEL) !== 0n || (perms & PermissionBits.ADMINISTRATOR) !== 0n;
});
// Sort by position
visibleChannels.sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
return reply.code(200).send(visibleChannels.map(rowToChannel));
});
// POST /api/spaces/:id/channels - Create a channel (admin+)
app.post<{ Params: { id: string }; Body: CreateChannelRequest }>('/api/spaces/:id/channels', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const { name, type, topic, categoryId } = request.body;
const db = getDb();
const space = db.select().from(schema.spaces).where(eq(schema.spaces.id, id)).get();
if (!space) {
return reply.code(404).send({ error: 'Space not found', statusCode: 404 });
}
if (!hasPermission(request.userId, id, PermissionBits.MANAGE_CHANNELS)) {
return reply.code(403).send({ error: 'Missing MANAGE_CHANNELS permission', statusCode: 403 });
}
if (!name || typeof name !== 'string') {
return reply.code(400).send({ error: 'Channel name is required', statusCode: 400 });
}
const trimmedName = name.trim().toLowerCase().replace(/\s+/g, '-');
if (trimmedName.length < 1 || trimmedName.length > 100) {
return reply.code(400).send({ error: 'Channel name must be between 1 and 100 characters', statusCode: 400 });
}
if (!type || !['text', 'voice'].includes(type)) {
return reply.code(400).send({ error: 'Channel type must be "text" or "voice"', statusCode: 400 });
}
// Validate categoryId if provided
let validCategoryId: string | null = null;
if (categoryId) {
const cat = db.select().from(schema.channelCategories)
.where(and(eq(schema.channelCategories.id, categoryId), eq(schema.channelCategories.spaceId, id)))
.get();
if (!cat) {
return reply.code(400).send({ error: 'Category not found in this space', statusCode: 400 });
}
validCategoryId = categoryId;
}
// Get max position for ordering
const existingChannels = db.select()
.from(schema.channels)
.where(eq(schema.channels.spaceId, id))
.all();
const maxPosition = existingChannels.reduce((max, ch) => Math.max(max, ch.position ?? 0), -1);
const channelId = generateSnowflake();
const now = Date.now();
db.insert(schema.channels).values({
id: channelId,
spaceId: id,
name: trimmedName,
type,
topic: topic?.trim() || null,
position: maxPosition + 1,
categoryId: validCategoryId,
createdAt: now,
}).run();
const channel = db.select().from(schema.channels).where(eq(schema.channels.id, channelId)).get();
if (!channel) {
return reply.code(500).send({ error: 'Failed to create channel', statusCode: 500 });
}
const channelData = rowToChannel(channel);
// Broadcast channel_created with per-user permissions
// (same pattern as broadcastOverrideChange — permissions are per-user
// so we must compute individually rather than broadcast uniformly)
for (const [userId, spaceIds] of connectionManager.getUserSpaceEntries()) {
if (!spaceIds.has(id)) continue;
const perms = computePermissions(userId, id, channelId);
if ((perms & PermissionBits.VIEW_CHANNEL) !== 0n) {
connectionManager.sendToUser(userId, {
type: 'channel_created',
channel: { ...channelData, isPrivate: false, myPermissions: permissionsToString(perms) },
spaceId: id,
});
}
}
// Return the channel with the creator's computed permissions (same shape as
// the channel_created WS event) so the client can render it immediately
// without waiting for the broadcast to round-trip.
recordAuditEvent({
spaceId: id,
actorId: request.userId,
action: 'channel.create',
targetType: 'channel',
targetId: channelId,
metadata: { name: channelData.name, type: channelData.type },
});
const creatorPerms = computePermissions(request.userId, id, channelId);
return reply.code(201).send({
...channelData,
isPrivate: false,
myPermissions: permissionsToString(creatorPerms),
});
});
// PATCH /api/channels/:id - Update a channel (admin+)
app.patch<{ Params: { id: string }; Body: UpdateChannelRequest }>('/api/channels/:id', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const { name, topic, position, categoryId } = request.body;
const db = getDb();
const channel = db.select().from(schema.channels).where(eq(schema.channels.id, id)).get();
if (!channel) {
return reply.code(404).send({ error: 'Channel not found', statusCode: 404 });
}
const spaceId = channel.spaceId;
if (!hasPermission(request.userId, spaceId, PermissionBits.MANAGE_CHANNELS, id)) {
return reply.code(403).send({ error: 'Missing MANAGE_CHANNELS permission', statusCode: 403 });
}
const updates: Partial<typeof schema.channels.$inferInsert> = {};
if (name !== undefined) {
const trimmedName = name.trim().toLowerCase().replace(/\s+/g, '-');
if (trimmedName.length < 1 || trimmedName.length > 100) {
return reply.code(400).send({ error: 'Channel name must be between 1 and 100 characters', statusCode: 400 });
}
updates.name = trimmedName;
}
if (topic !== undefined) {
updates.topic = topic.trim() || null;
}
if (position !== undefined) {
if (typeof position !== 'number' || position < 0) {
return reply.code(400).send({ error: 'Position must be a non-negative number', statusCode: 400 });
}
updates.position = position;
}
if (categoryId !== undefined) {
if (categoryId === null) {
updates.categoryId = null;
} else {
const cat = db.select().from(schema.channelCategories)
.where(and(eq(schema.channelCategories.id, categoryId), eq(schema.channelCategories.spaceId, spaceId)))
.get();
if (!cat) {
return reply.code(400).send({ error: 'Category not found in this space', statusCode: 400 });
}
updates.categoryId = categoryId;
}
}
if (Object.keys(updates).length === 0) {
return reply.code(400).send({ error: 'No fields to update', statusCode: 400 });
}
db.update(schema.channels).set(updates).where(eq(schema.channels.id, id)).run();
const updated = db.select().from(schema.channels).where(eq(schema.channels.id, id)).get();
if (!updated) {
return reply.code(500).send({ error: 'Failed to update channel', statusCode: 500 });
}
const channelData = rowToChannel(updated);
// If categoryId changed, permissions may have changed due to different category overrides
if (categoryId !== undefined) {
broadcastOverrideChange(spaceId, id);
if (channel.type === 'voice') {
checkVoicePermissions(spaceId);
}
} else {
// Simple broadcast for non-permission-affecting changes
connectionManager.sendToChannel(spaceId, id, {
type: 'channel_updated',
channel: channelData,
spaceId,
});
}
recordAuditEvent({
spaceId,
actorId: request.userId,
action: 'channel.update',
targetType: 'channel',
targetId: id,
metadata: { name: channelData.name },
});
return reply.code(200).send(channelData);
});
// DELETE /api/channels/:id - Delete a channel (admin+)
app.delete<{ Params: { id: string } }>('/api/channels/:id', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const db = getDb();
const channel = db.select().from(schema.channels).where(eq(schema.channels.id, id)).get();
if (!channel) {
return reply.code(404).send({ error: 'Channel not found', statusCode: 404 });
}
const spaceId = channel.spaceId;
if (!hasPermission(request.userId, spaceId, PermissionBits.MANAGE_CHANNELS, id)) {
return reply.code(403).send({ error: 'Missing MANAGE_CHANNELS permission', statusCode: 403 });
}
// Disconnect voice users before deletion
const participants = connectionManager.getRoomParticipants(id);
if (participants.size > 0) {
for (const participantId of Array.from(participants)) {
connectionManager.leaveRoom(id, participantId);
connectionManager.clearVoiceUserStatus(participantId);
connectionManager.sendToSpace(spaceId, {
type: 'voice_state_update', channelId: id, userId: participantId, action: 'leave',
});
connectionManager.sendToUser(participantId, {
type: 'voice_disconnected', userId: participantId, channelId: id,
});
}
}
// Collect viewers BEFORE deleting (overrides CASCADE-delete with the channel)
const viewerIds: string[] = [];
for (const [uid, spaceIds] of connectionManager.getUserSpaceEntries()) {
if (spaceIds.has(spaceId)) {
const perms = computePermissions(uid, spaceId, id);
if ((perms & PermissionBits.VIEW_CHANNEL) !== 0n) {
viewerIds.push(uid);
}
}
}
// Collect attachment filenames BEFORE cascade deletes DB records
const channelMsgIds = db.select({ id: schema.messages.id })
.from(schema.messages).where(eq(schema.messages.channelId, id)).all().map(m => m.id);
let attachmentRows: { filename: string }[] = [];
if (channelMsgIds.length > 0) {
attachmentRows = db.select({ filename: schema.attachments.filename })
.from(schema.attachments).where(inArray(schema.attachments.messageId, channelMsgIds)).all();
}
// Clean up read_states (no FK, rows would be orphaned)
db.delete(schema.readStates).where(eq(schema.readStates.channelId, id)).run();
// Delete messages in channel (attachments cascade), then channel
db.delete(schema.messages).where(eq(schema.messages.channelId, id)).run();
db.delete(schema.channels).where(eq(schema.channels.id, id)).run();
// Delete attachment files from disk
deleteAttachmentFiles(attachmentRows);
// Broadcast channel_deleted only to users who could see the channel
const deleteEvent = { type: 'channel_deleted' as const, channelId: id, spaceId };
for (const uid of viewerIds) {
connectionManager.sendToUser(uid, deleteEvent);
}
recordAuditEvent({
spaceId,
actorId: request.userId,
action: 'channel.delete',
targetType: 'channel',
targetId: id,
metadata: { name: channel.name },
});
return reply.code(200).send({ success: true });
});
// ─── Channel Override Endpoints ───────────────────────────────────────────
// GET /api/channels/:id/overrides - List channel permission overrides
app.get<{ Params: { id: string } }>('/api/channels/:id/overrides', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const db = getDb();
const channel = db.select().from(schema.channels).where(eq(schema.channels.id, id)).get();
if (!channel) {
return reply.code(404).send({ error: 'Channel not found', statusCode: 404 });
}
if (!hasPermission(request.userId, channel.spaceId, PermissionBits.MANAGE_ROLES)) {
return reply.code(403).send({ error: 'Missing MANAGE_ROLES permission', statusCode: 403 });
}
const overrides = db.select().from(schema.channelOverrides)
.where(eq(schema.channelOverrides.channelId, id))
.all();
return reply.code(200).send(overrides.map(o => ({
channelId: o.channelId,
targetType: o.targetType,
targetId: o.targetId,
allow: o.allow,
deny: o.deny,
})));
});
// PUT /api/channels/:id/overrides - Create or update a channel override
app.put<{
Params: { id: string };
Body: { targetType: string; targetId: string; allow: string; deny: string };
}>('/api/channels/:id/overrides', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const { targetType, targetId, allow, deny } = request.body;
const db = getDb();
if (!targetType || !['role', 'member'].includes(targetType)) {
return reply.code(400).send({ error: 'targetType must be "role" or "member"', statusCode: 400 });
}
if (!targetId || typeof targetId !== 'string') {
return reply.code(400).send({ error: 'targetId is required', statusCode: 400 });
}
const channel = db.select().from(schema.channels).where(eq(schema.channels.id, id)).get();
if (!channel) {
return reply.code(404).send({ error: 'Channel not found', statusCode: 404 });
}
if (!hasPermission(request.userId, channel.spaceId, PermissionBits.MANAGE_ROLES)) {
return reply.code(403).send({ error: 'Missing MANAGE_ROLES permission', statusCode: 403 });
}
// Validate that allow/deny are valid bigint strings
let allowBits: bigint;
let denyBits: bigint;
try {
allowBits = BigInt(allow || '0');
denyBits = BigInt(deny || '0');
} catch {
return reply.code(400).send({ error: 'allow and deny must be valid decimal integer strings', statusCode: 400 });
}
// Privilege escalation guard: non-admin users can only grant permissions they possess
const callerPerms = computePermissions(request.userId, channel.spaceId);
if ((callerPerms & PermissionBits.ADMINISTRATOR) === 0n) {
const escalatedAllow = allowBits & ~callerPerms;
if (escalatedAllow !== 0n) {
return reply.code(403).send({ error: 'Cannot grant permissions you do not possess', statusCode: 403 });
}
const escalatedDeny = denyBits & ~callerPerms;
if (escalatedDeny !== 0n) {
return reply.code(403).send({ error: 'Cannot deny permissions you do not possess', statusCode: 403 });
}
}
// Upsert: delete existing then insert
db.transaction((tx) => {
tx.delete(schema.channelOverrides).where(
and(
eq(schema.channelOverrides.channelId, id),
eq(schema.channelOverrides.targetType, targetType),
eq(schema.channelOverrides.targetId, targetId),
)
).run();
tx.insert(schema.channelOverrides).values({
channelId: id,
targetType,
targetId,
allow: allow || '0',
deny: deny || '0',
}).run();
});
// Notify all space members of the permission change
broadcastOverrideChange(channel.spaceId, id);
checkVoicePermissions(channel.spaceId);
return reply.code(200).send({ success: true });
});
// DELETE /api/channels/:id/overrides/:targetType/:targetId - Remove a channel override
app.delete<{ Params: { id: string; targetType: string; targetId: string } }>(
'/api/channels/:id/overrides/:targetType/:targetId',
{ preHandler: authenticate },
async (request, reply) => {
const { id, targetType, targetId } = request.params;
const db = getDb();
const channel = db.select().from(schema.channels).where(eq(schema.channels.id, id)).get();
if (!channel) {
return reply.code(404).send({ error: 'Channel not found', statusCode: 404 });
}
if (!hasPermission(request.userId, channel.spaceId, PermissionBits.MANAGE_ROLES)) {
return reply.code(403).send({ error: 'Missing MANAGE_ROLES permission', statusCode: 403 });
}
db.delete(schema.channelOverrides).where(
and(
eq(schema.channelOverrides.channelId, id),
eq(schema.channelOverrides.targetType, targetType),
eq(schema.channelOverrides.targetId, targetId),
)
).run();
// Notify all space members of the permission change
broadcastOverrideChange(channel.spaceId, id);
checkVoicePermissions(channel.spaceId);
return reply.code(200).send({ success: true });
},
);
// ─── Category Override Endpoints ─────────────────────────────────────────
// GET /api/categories/:id/overrides
app.get<{ Params: { id: string } }>('/api/categories/:id/overrides', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const db = getDb();
const category = db.select().from(schema.channelCategories)
.where(eq(schema.channelCategories.id, id)).get();
if (!category) {
return reply.code(404).send({ error: 'Category not found', statusCode: 404 });
}
if (!isMember(category.spaceId, request.userId)) {
return reply.code(403).send({ error: 'Not a member of this space', statusCode: 403 });
}
if (!hasPermission(request.userId, category.spaceId, PermissionBits.MANAGE_ROLES)) {
return reply.code(403).send({ error: 'Missing MANAGE_ROLES permission', statusCode: 403 });
}
const overrides = db.select().from(schema.categoryOverrides)
.where(eq(schema.categoryOverrides.categoryId, id))
.all();
return reply.code(200).send(overrides.map(o => ({
categoryId: o.categoryId,
targetType: o.targetType,
targetId: o.targetId,
allow: o.allow,
deny: o.deny,
})));
});
// PUT /api/categories/:id/overrides
app.put<{
Params: { id: string };
Body: { targetType: string; targetId: string; allow: string; deny: string };
}>('/api/categories/:id/overrides', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const { targetType, targetId, allow, deny } = request.body;
const db = getDb();
if (!targetType || !['role', 'member'].includes(targetType)) {
return reply.code(400).send({ error: 'targetType must be "role" or "member"', statusCode: 400 });
}
if (!targetId || typeof targetId !== 'string') {
return reply.code(400).send({ error: 'targetId is required', statusCode: 400 });
}
const category = db.select().from(schema.channelCategories)
.where(eq(schema.channelCategories.id, id)).get();
if (!category) {
return reply.code(404).send({ error: 'Category not found', statusCode: 404 });
}
if (!hasPermission(request.userId, category.spaceId, PermissionBits.MANAGE_ROLES)) {
return reply.code(403).send({ error: 'Missing MANAGE_ROLES permission', statusCode: 403 });
}
let allowBits: bigint;
let denyBits: bigint;
try {
allowBits = BigInt(allow || '0');
denyBits = BigInt(deny || '0');
} catch {
return reply.code(400).send({ error: 'allow and deny must be valid decimal integer strings', statusCode: 400 });
}
// Privilege escalation guard (matches channel override pattern)
const callerPerms = computePermissions(request.userId, category.spaceId);
if ((callerPerms & PermissionBits.ADMINISTRATOR) === 0n) {
const escalatedAllow = allowBits & ~callerPerms;
if (escalatedAllow !== 0n) {
return reply.code(403).send({ error: 'Cannot grant permissions you do not possess', statusCode: 403 });
}
const escalatedDeny = denyBits & ~callerPerms;
if (escalatedDeny !== 0n) {
return reply.code(403).send({ error: 'Cannot deny permissions you do not possess', statusCode: 403 });
}
}
db.transaction((tx) => {
tx.delete(schema.categoryOverrides).where(
and(
eq(schema.categoryOverrides.categoryId, id),
eq(schema.categoryOverrides.targetType, targetType),
eq(schema.categoryOverrides.targetId, targetId),
)
).run();
tx.insert(schema.categoryOverrides).values({
categoryId: id,
targetType,
targetId,
allow: allow || '0',
deny: deny || '0',
}).run();
});
broadcastCategoryOverrideChange(category.spaceId, id);
checkVoicePermissions(category.spaceId);
return reply.code(200).send({ success: true });
});
// DELETE /api/categories/:id/overrides/:targetType/:targetId
app.delete<{ Params: { id: string; targetType: string; targetId: string } }>(
'/api/categories/:id/overrides/:targetType/:targetId',
{ preHandler: authenticate },
async (request, reply) => {
const { id, targetType, targetId } = request.params;
const db = getDb();
const category = db.select().from(schema.channelCategories)
.where(eq(schema.channelCategories.id, id)).get();
if (!category) {
return reply.code(404).send({ error: 'Category not found', statusCode: 404 });
}
if (!hasPermission(request.userId, category.spaceId, PermissionBits.MANAGE_ROLES)) {
return reply.code(403).send({ error: 'Missing MANAGE_ROLES permission', statusCode: 403 });
}
db.delete(schema.categoryOverrides).where(
and(
eq(schema.categoryOverrides.categoryId, id),
eq(schema.categoryOverrides.targetType, targetType),
eq(schema.categoryOverrides.targetId, targetId),
)
).run();
broadcastCategoryOverrideChange(category.spaceId, id);
checkVoicePermissions(category.spaceId);
return reply.code(200).send({ success: true });
},
);
// ─── Channel Category Endpoints ─────────────────────────────────────────────
// POST /api/spaces/:id/categories - Create a category
app.post<{ Params: { id: string }; Body: { name: string } }>('/api/spaces/:id/categories', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const { name } = request.body;
const db = getDb();
const space = db.select().from(schema.spaces).where(eq(schema.spaces.id, id)).get();
if (!space) {
return reply.code(404).send({ error: 'Space not found', statusCode: 404 });
}
if (!hasPermission(request.userId, id, PermissionBits.MANAGE_CHANNELS)) {
return reply.code(403).send({ error: 'Missing MANAGE_CHANNELS permission', statusCode: 403 });
}
if (!name || typeof name !== 'string' || !name.trim()) {
return reply.code(400).send({ error: 'Category name is required', statusCode: 400 });
}
const trimmedName = name.trim();
if (trimmedName.length > 100) {
return reply.code(400).send({ error: 'Category name must be 100 characters or less', statusCode: 400 });
}
const existing = db.select().from(schema.channelCategories)
.where(eq(schema.channelCategories.spaceId, id))
.all();
const maxPos = existing.reduce((max, c) => Math.max(max, c.position ?? 0), -1);
const categoryId = generateSnowflake();
const now = Date.now();
db.insert(schema.channelCategories).values({
id: categoryId,
spaceId: id,
name: trimmedName,
position: maxPos + 1,
createdAt: now,
}).run();
const category = db.select().from(schema.channelCategories)
.where(eq(schema.channelCategories.id, categoryId)).get();
if (!category) {
return reply.code(500).send({ error: 'Failed to create category', statusCode: 500 });
}
const categoryData = rowToCategory(category);
connectionManager.sendToSpace(id, {
type: 'category_created',
category: categoryData,
spaceId: id,
});
return reply.code(201).send(categoryData);
});
// PATCH /api/categories/:id - Update a category
app.patch<{ Params: { id: string }; Body: { name?: string; position?: number } }>('/api/categories/:id', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const { name, position } = request.body;
const db = getDb();
const category = db.select().from(schema.channelCategories)
.where(eq(schema.channelCategories.id, id)).get();
if (!category) {
return reply.code(404).send({ error: 'Category not found', statusCode: 404 });
}
if (!hasPermission(request.userId, category.spaceId, PermissionBits.MANAGE_CHANNELS)) {
return reply.code(403).send({ error: 'Missing MANAGE_CHANNELS permission', statusCode: 403 });
}
const updates: Partial<typeof schema.channelCategories.$inferInsert> = {};
if (name !== undefined) {
const trimmedName = name.trim();
if (!trimmedName || trimmedName.length > 100) {
return reply.code(400).send({ error: 'Category name must be 1-100 characters', statusCode: 400 });
}
updates.name = trimmedName;
}
if (position !== undefined) {
if (typeof position !== 'number' || position < 0) {
return reply.code(400).send({ error: 'Position must be a non-negative number', statusCode: 400 });
}
updates.position = position;
}
if (Object.keys(updates).length === 0) {
return reply.code(400).send({ error: 'No fields to update', statusCode: 400 });
}
db.update(schema.channelCategories).set(updates)
.where(eq(schema.channelCategories.id, id)).run();
const updated = db.select().from(schema.channelCategories)
.where(eq(schema.channelCategories.id, id)).get();
if (!updated) {
return reply.code(500).send({ error: 'Failed to update category', statusCode: 500 });
}
const updatedData = { ...rowToCategory(updated), isPrivate: isCategoryPrivate(id, category.spaceId) };
connectionManager.sendToSpace(category.spaceId, {
type: 'category_updated',
category: updatedData,
spaceId: category.spaceId,
});
return reply.code(200).send(updatedData);
});
// DELETE /api/categories/:id - Delete a category
app.delete<{ Params: { id: string } }>('/api/categories/:id', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const db = getDb();
const category = db.select().from(schema.channelCategories)
.where(eq(schema.channelCategories.id, id)).get();
if (!category) {
return reply.code(404).send({ error: 'Category not found', statusCode: 404 });
}
if (!hasPermission(request.userId, category.spaceId, PermissionBits.MANAGE_CHANNELS)) {
return reply.code(403).send({ error: 'Missing MANAGE_CHANNELS permission', statusCode: 403 });
}
const spaceId = category.spaceId;
db.transaction((tx) => {
// Null out categoryId on all channels in this category
tx.update(schema.channels).set({ categoryId: null })
.where(eq(schema.channels.categoryId, id)).run();
// Delete the category
tx.delete(schema.channelCategories)
.where(eq(schema.channelCategories.id, id)).run();
});
// Broadcast category deletion
connectionManager.sendToSpace(spaceId, {
type: 'category_deleted',
categoryId: id,
spaceId,
});
// Also broadcast updated layout so channels reflect null categoryId
broadcastChannelLayout(spaceId);
return reply.code(200).send({ success: true });
});
// PATCH /api/spaces/:id/channel-layout - Batch reorder channels + categories
app.patch<{
Params: { id: string };
Body: {
channels: Array<{ id: string; position: number; categoryId: string | null }>;
categories: Array<{ id: string; position: number }>;
};
}>('/api/spaces/:id/channel-layout', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const { channels: channelUpdates, categories: categoryUpdates } = request.body;
const db = getDb();
const space = db.select().from(schema.spaces).where(eq(schema.spaces.id, id)).get();
if (!space) {
return reply.code(404).send({ error: 'Space not found', statusCode: 404 });
}
if (!hasPermission(request.userId, id, PermissionBits.MANAGE_CHANNELS)) {
return reply.code(403).send({ error: 'Missing MANAGE_CHANNELS permission', statusCode: 403 });
}
if (!Array.isArray(channelUpdates) || !Array.isArray(categoryUpdates)) {
return reply.code(400).send({ error: 'channels and categories arrays are required', statusCode: 400 });
}
// Validate all channel IDs belong to this space
const spaceChannels = db.select().from(schema.channels)
.where(eq(schema.channels.spaceId, id)).all();
const spaceChannelIds = new Set(spaceChannels.map(ch => ch.id));
for (const ch of channelUpdates) {
if (!spaceChannelIds.has(ch.id)) {
return reply.code(400).send({ error: `Channel ${ch.id} does not belong to this space`, statusCode: 400 });
}
if (typeof ch.position !== 'number' || ch.position < 0) {
return reply.code(400).send({ error: 'All positions must be non-negative numbers', statusCode: 400 });
}
}
// Validate all category IDs belong to this space
const spaceCategories = db.select().from(schema.channelCategories)
.where(eq(schema.channelCategories.spaceId, id)).all();
const spaceCategoryIds = new Set(spaceCategories.map(c => c.id));
for (const cat of categoryUpdates) {
if (!spaceCategoryIds.has(cat.id)) {
return reply.code(400).send({ error: `Category ${cat.id} does not belong to this space`, statusCode: 400 });
}
if (typeof cat.position !== 'number' || cat.position < 0) {
return reply.code(400).send({ error: 'All positions must be non-negative numbers', statusCode: 400 });
}
}
// Validate category references in channels
for (const ch of channelUpdates) {
if (ch.categoryId !== null && !spaceCategoryIds.has(ch.categoryId)) {
return reply.code(400).send({ error: `Category ${ch.categoryId} does not belong to this space`, statusCode: 400 });
}
}
// Apply all updates in a transaction
db.transaction((tx) => {
for (const ch of channelUpdates) {
tx.update(schema.channels)
.set({ position: ch.position, categoryId: ch.categoryId })
.where(eq(schema.channels.id, ch.id))
.run();
}
for (const cat of categoryUpdates) {
tx.update(schema.channelCategories)
.set({ position: cat.position })
.where(eq(schema.channelCategories.id, cat.id))
.run();
}
});
// Broadcast the updated layout to all space members with per-user channel filtering
broadcastChannelLayout(id);
return reply.code(200).send({ success: true });
});
}
/**
* Broadcast updated channel layout to all space members.
* Each user gets only the channels they can view (VIEW_CHANNEL check).
*/
function broadcastChannelLayout(spaceId: string): void {
const db = getDb();
const allChannels = db.select().from(schema.channels)
.where(eq(schema.channels.spaceId, spaceId)).all();
const allCategories = db.select().from(schema.channelCategories)
.where(eq(schema.channelCategories.spaceId, spaceId)).all();
const categoryData = allCategories.map(c => ({
...rowToCategory(c),
isPrivate: isCategoryPrivate(c.id, spaceId),
}));
for (const [userId, spaceIds] of connectionManager.getUserSpaceEntries()) {
if (!spaceIds.has(spaceId)) continue;
const visibleChannels: Channel[] = [];
for (const ch of allChannels) {
const perms = computePermissions(userId, spaceId, ch.id);
if ((perms & PermissionBits.VIEW_CHANNEL) !== 0n) {
visibleChannels.push({
...rowToChannel(ch),
isPrivate: isChannelPrivate(ch.id, spaceId),
myPermissions: permissionsToString(perms),
});
}
}
connectionManager.sendToUser(userId, {
type: 'channel_layout_updated',
spaceId,
channels: visibleChannels,
categories: categoryData,
});
}
}