diff --git a/packages/server/src/db/index.ts b/packages/server/src/db/index.ts index c1348ad8..d7be0100 100644 --- a/packages/server/src/db/index.ts +++ b/packages/server/src/db/index.ts @@ -144,6 +144,15 @@ function createTables(db: Database.Database): void { PRIMARY KEY (server_id, user_id, role_id) ); + CREATE TABLE IF NOT EXISTS channel_overrides ( + channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + target_type TEXT NOT NULL, + target_id TEXT NOT NULL, + allow TEXT NOT NULL DEFAULT '0', + deny TEXT NOT NULL DEFAULT '0', + PRIMARY KEY (channel_id, target_type, target_id) + ); + CREATE TABLE IF NOT EXISTS read_states ( user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, channel_id TEXT NOT NULL, diff --git a/packages/server/src/db/migrate.ts b/packages/server/src/db/migrate.ts index 354c718a..06624bcc 100644 --- a/packages/server/src/db/migrate.ts +++ b/packages/server/src/db/migrate.ts @@ -1,4 +1,5 @@ import Database from 'better-sqlite3'; +import { DEFAULT_EVERYONE_PERMISSIONS, PermissionBits, ALL_PERMISSIONS, permissionsToString } from '@opencord/shared/src/permissions.js'; export function runMigrations(db: Database.Database): void { console.log('Checking for database migrations...'); @@ -65,6 +66,75 @@ export function runMigrations(db: Database.Database): void { } } } - + + // Ensure channel_overrides table exists (idempotent) + db.exec(` + CREATE TABLE IF NOT EXISTS channel_overrides ( + channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + target_type TEXT NOT NULL, + target_id TEXT NOT NULL, + allow TEXT NOT NULL DEFAULT '0', + deny TEXT NOT NULL DEFAULT '0', + PRIMARY KEY (channel_id, target_type, target_id) + ); + `); + + // ─── RBAC Migration: Ensure @everyone roles exist for all servers ───────── + migrateEveryoneRoles(db); + console.log('Migrations complete.'); } + +/** For each server, ensure an @everyone role exists with id === server.id */ +function migrateEveryoneRoles(db: Database.Database): void { + const servers = db.prepare('SELECT id FROM servers').all() as { id: string }[]; + const now = Date.now(); + const defaultPerms = permissionsToString(DEFAULT_EVERYONE_PERMISSIONS); + const adminPerms = permissionsToString(ALL_PERMISSIONS); + + const insertRole = db.prepare( + 'INSERT OR IGNORE INTO roles (id, server_id, name, color, position, permissions, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)' + ); + + for (const server of servers) { + // Create @everyone role if it doesn't exist (id = server.id) + insertRole.run(server.id, server.id, '@everyone', '#b9bbbe', 0, defaultPerms, now); + } + + // Migrate existing admin members: ensure an Admin role exists and assign it + const adminMembers = db.prepare( + "SELECT server_id, user_id FROM server_members WHERE role = 'admin'" + ).all() as { server_id: string; user_id: string }[]; + + if (adminMembers.length > 0) { + // Group by server + const serverAdmins = new Map(); + for (const row of adminMembers) { + let arr = serverAdmins.get(row.server_id); + if (!arr) { arr = []; serverAdmins.set(row.server_id, arr); } + arr.push(row.user_id); + } + + const checkAdminRole = db.prepare( + "SELECT id FROM roles WHERE server_id = ? AND name = 'Admin' AND permissions = ?" + ); + const insertMemberRole = db.prepare( + 'INSERT OR IGNORE INTO member_roles (server_id, user_id, role_id) VALUES (?, ?, ?)' + ); + + for (const [serverId, userIds] of serverAdmins) { + // Find or create Admin role for this server + let adminRole = checkAdminRole.get(serverId, adminPerms) as { id: string } | undefined; + if (!adminRole) { + // Generate a simple unique ID for the admin role + const adminRoleId = `${serverId}-admin`; + insertRole.run(adminRoleId, serverId, 'Admin', '#e74c3c', 1, adminPerms, now); + adminRole = { id: adminRoleId }; + } + + for (const userId of userIds) { + insertMemberRole.run(serverId, userId, adminRole.id); + } + } + } +} diff --git a/packages/server/src/db/schema.ts b/packages/server/src/db/schema.ts index 202d67d3..f7cc6603 100644 --- a/packages/server/src/db/schema.ts +++ b/packages/server/src/db/schema.ts @@ -140,6 +140,16 @@ export const memberRoles = sqliteTable('member_roles', { pk: primaryKey({ columns: [table.serverId, table.userId, table.roleId] }), })); +export const channelOverrides = sqliteTable('channel_overrides', { + channelId: text('channel_id').notNull().references(() => channels.id, { onDelete: 'cascade' }), + targetType: text('target_type').notNull(), // 'role' | 'member' + targetId: text('target_id').notNull(), // role ID or user ID + allow: text('allow').notNull().default('0'), // BigInt decimal string + deny: text('deny').notNull().default('0'), // BigInt decimal string +}, (table) => ({ + pk: primaryKey({ columns: [table.channelId, table.targetType, table.targetId] }), +})); + export const readStates = sqliteTable('read_states', { userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), channelId: text('channel_id').notNull(), diff --git a/packages/server/src/db/seed.ts b/packages/server/src/db/seed.ts index 55d18bc4..3a4c337d 100644 --- a/packages/server/src/db/seed.ts +++ b/packages/server/src/db/seed.ts @@ -2,6 +2,7 @@ import { getDb, schema } from './index.js'; import { generateSnowflake } from '../utils/snowflake.js'; import { hashPassword } from '../utils/auth.js'; import { eq } from 'drizzle-orm'; +import { DEFAULT_EVERYONE_PERMISSIONS, permissionsToString } from '@opencord/shared/src/permissions.js'; export async function seedDatabase(): Promise { const db = getDb(); @@ -63,6 +64,17 @@ export async function seedDatabase(): Promise { createdAt: Date.now(), }).run(); + // Create @everyone role (id === serverId convention) + db.insert(schema.roles).values({ + id: serverId, + serverId: serverId, + name: '@everyone', + color: '#b9bbbe', + position: 0, + permissions: permissionsToString(DEFAULT_EVERYONE_PERMISSIONS), + createdAt: Date.now(), + }).run(); + console.log('Database seeded successfully'); console.log(` Default server: Opencord (invite code: opencord)`); console.log(` Admin user: admin / admin123`); diff --git a/packages/server/src/routes/channels.ts b/packages/server/src/routes/channels.ts index e440ae97..7fbbb2da 100644 --- a/packages/server/src/routes/channels.ts +++ b/packages/server/src/routes/channels.ts @@ -1,9 +1,9 @@ import type { FastifyInstance } from 'fastify'; -import { eq } from 'drizzle-orm'; +import { eq, and } from 'drizzle-orm'; import { getDb, schema } from '../db/index.js'; import { authenticate } from '../utils/auth.js'; import { generateSnowflake } from '../utils/snowflake.js'; -import { isMember, isAdmin, getChannelServerId } from '../utils/permissions.js'; +import { isMember, hasPermission, getChannelServerId, PermissionBits, computePermissions } from '../utils/permissions.js'; import { connectionManager } from '../ws/handler.js'; import type { CreateChannelRequest, @@ -40,15 +40,21 @@ export async function channelRoutes(app: FastifyInstance): Promise { return reply.code(403).send({ error: 'You are not a member of this server', statusCode: 403 }); } - const channels = db.select() + const allChannels = db.select() .from(schema.channels) .where(eq(schema.channels.serverId, id)) .all(); - // Sort by position - channels.sort((a, b) => (a.position ?? 0) - (b.position ?? 0)); + // 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; + }); - return reply.code(200).send(channels.map(rowToChannel)); + // Sort by position + visibleChannels.sort((a, b) => (a.position ?? 0) - (b.position ?? 0)); + + return reply.code(200).send(visibleChannels.map(rowToChannel)); }); // POST /api/servers/:id/channels - Create a channel (admin+) @@ -64,8 +70,8 @@ export async function channelRoutes(app: FastifyInstance): Promise { return reply.code(404).send({ error: 'Server not found', statusCode: 404 }); } - if (!isAdmin(id, request.userId)) { - return reply.code(403).send({ error: 'Only admins can create channels', statusCode: 403 }); + 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') { @@ -133,8 +139,8 @@ export async function channelRoutes(app: FastifyInstance): Promise { } const serverId = channel.serverId; - if (!isAdmin(serverId, request.userId)) { - return reply.code(403).send({ error: 'Only admins can update channels', statusCode: 403 }); + if (!hasPermission(request.userId, serverId, PermissionBits.MANAGE_CHANNELS, id)) { + return reply.code(403).send({ error: 'Missing MANAGE_CHANNELS permission', statusCode: 403 }); } const updates: Partial = {}; @@ -194,8 +200,8 @@ export async function channelRoutes(app: FastifyInstance): Promise { } const serverId = channel.serverId; - if (!isAdmin(serverId, request.userId)) { - return reply.code(403).send({ error: 'Only admins can delete channels', statusCode: 403 }); + if (!hasPermission(request.userId, serverId, PermissionBits.MANAGE_CHANNELS, id)) { + return reply.code(403).send({ error: 'Missing MANAGE_CHANNELS permission', statusCode: 403 }); } // Delete messages in channel (attachments cascade), then channel @@ -211,4 +217,121 @@ export async function channelRoutes(app: FastifyInstance): Promise { 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.serverId, 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.serverId, PermissionBits.MANAGE_ROLES)) { + return reply.code(403).send({ error: 'Missing MANAGE_ROLES permission', statusCode: 403 }); + } + + // Validate that allow/deny are valid bigint strings + try { + BigInt(allow || '0'); + BigInt(deny || '0'); + } catch { + return reply.code(400).send({ error: 'allow and deny must be valid decimal integer strings', statusCode: 400 }); + } + + // 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(); + }); + + 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.serverId, 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(); + + return reply.code(200).send({ success: true }); + }, + ); } diff --git a/packages/server/src/routes/livekit.ts b/packages/server/src/routes/livekit.ts index c67c140d..ebd3dd22 100644 --- a/packages/server/src/routes/livekit.ts +++ b/packages/server/src/routes/livekit.ts @@ -2,7 +2,7 @@ import type { FastifyInstance } from 'fastify'; import { AccessToken } from 'livekit-server-sdk'; import { authenticate } from '../utils/auth.js'; import { config } from '../config.js'; -import { getChannelServerId, isMember, isDmMember } from '../utils/permissions.js'; +import { getChannelServerId, hasPermission, isDmMember, PermissionBits } from '../utils/permissions.js'; import type { LiveKitTokenRequest, LiveKitTokenResponse } from '@opencord/shared'; export async function livekitRoutes(app: FastifyInstance): Promise { @@ -30,8 +30,8 @@ export async function livekitRoutes(app: FastifyInstance): Promise { if (!serverId) { return reply.code(404).send({ error: 'Channel not found', statusCode: 404 }); } - if (!isMember(serverId, request.userId)) { - return reply.code(403).send({ error: 'You are not a member of this server', statusCode: 403 }); + if (!hasPermission(request.userId, serverId, PermissionBits.CONNECT, channelId)) { + return reply.code(403).send({ error: 'Missing CONNECT permission', statusCode: 403 }); } roomName = channelId; } else { diff --git a/packages/server/src/routes/messages.ts b/packages/server/src/routes/messages.ts index e233ff3e..f97ade51 100644 --- a/packages/server/src/routes/messages.ts +++ b/packages/server/src/routes/messages.ts @@ -3,7 +3,7 @@ import { eq, and, desc, lt, inArray } from 'drizzle-orm'; import { getDb, schema } from '../db/index.js'; import { authenticate } from '../utils/auth.js'; import { generateSnowflake } from '../utils/snowflake.js'; -import { isMember, getChannelServerId, isAdmin } from '../utils/permissions.js'; +import { hasPermission, getChannelServerId, PermissionBits } from '../utils/permissions.js'; import { connectionManager } from '../ws/handler.js'; import type { CreateMessageRequest, @@ -175,8 +175,8 @@ export async function messageRoutes(app: FastifyInstance): Promise { return reply.code(404).send({ error: 'Channel not found', statusCode: 404 }); } - if (!isMember(serverId, request.userId)) { - return reply.code(403).send({ error: 'You are not a member of this server', statusCode: 403 }); + if (!hasPermission(request.userId, serverId, PermissionBits.VIEW_CHANNEL | PermissionBits.READ_MESSAGE_HISTORY, id)) { + return reply.code(403).send({ error: 'Missing VIEW_CHANNEL or READ_MESSAGE_HISTORY permission', statusCode: 403 }); } const db = getDb(); @@ -268,8 +268,8 @@ export async function messageRoutes(app: FastifyInstance): Promise { return reply.code(404).send({ error: 'Channel not found', statusCode: 404 }); } - if (!isMember(serverId, request.userId)) { - return reply.code(403).send({ error: 'You are not a member of this server', statusCode: 403 }); + if (!hasPermission(request.userId, serverId, PermissionBits.SEND_MESSAGES, id)) { + return reply.code(403).send({ error: 'Missing SEND_MESSAGES permission', statusCode: 403 }); } if ((!content || typeof content !== 'string' || content.trim().length === 0) && @@ -418,9 +418,9 @@ export async function messageRoutes(app: FastifyInstance): Promise { } const isAuthor = message.userId === request.userId; - const isAdminUser = isAdmin(serverId, request.userId); + const canManageMessages = hasPermission(request.userId, serverId, PermissionBits.MANAGE_MESSAGES, message.channelId); - if (!isAuthor && !isAdminUser) { + if (!isAuthor && !canManageMessages) { return reply.code(403).send({ error: 'You cannot delete this message', statusCode: 403 }); } diff --git a/packages/server/src/routes/servers.ts b/packages/server/src/routes/servers.ts index fb747c68..465e10f2 100644 --- a/packages/server/src/routes/servers.ts +++ b/packages/server/src/routes/servers.ts @@ -3,7 +3,8 @@ import { eq, and, inArray } from 'drizzle-orm'; import { getDb, schema } from '../db/index.js'; import { authenticate } from '../utils/auth.js'; import { generateSnowflake } from '../utils/snowflake.js'; -import { isMember, isOwner, isAdmin } from '../utils/permissions.js'; +import { isMember, isServerOwner, hasPermission, PermissionBits } from '../utils/permissions.js'; +import { DEFAULT_EVERYONE_PERMISSIONS, permissionsToString } from '@opencord/shared/src/permissions.js'; import crypto from 'crypto'; import { connectionManager } from '../ws/handler.js'; import type { @@ -80,7 +81,7 @@ export async function serverRoutes(app: FastifyInstance): Promise { const now = Date.now(); const inviteCode = generateInviteCode(); - // Create server, owner membership, and default channel atomically + // Create server, owner membership, default channel, and @everyone role atomically db.transaction((tx) => { tx.insert(schema.servers).values({ id: serverId, @@ -106,6 +107,17 @@ export async function serverRoutes(app: FastifyInstance): Promise { position: 0, createdAt: now, }).run(); + + // Auto-create @everyone role (id = serverId) + tx.insert(schema.roles).values({ + id: serverId, + serverId, + name: '@everyone', + color: '#b9bbbe', + position: 0, + permissions: permissionsToString(DEFAULT_EVERYONE_PERMISSIONS), + createdAt: now, + }).run(); }); const server = db.select().from(schema.servers).where(eq(schema.servers.id, serverId)).get(); @@ -246,8 +258,8 @@ export async function serverRoutes(app: FastifyInstance): Promise { return reply.code(404).send({ error: 'Server not found', statusCode: 404 }); } - if (!isOwner(id, request.userId)) { - return reply.code(403).send({ error: 'Only the server owner can update the server', statusCode: 403 }); + if (!hasPermission(request.userId, id, PermissionBits.MANAGE_SERVER)) { + return reply.code(403).send({ error: 'Missing MANAGE_SERVER permission', statusCode: 403 }); } const updates: Partial = {}; @@ -298,7 +310,7 @@ export async function serverRoutes(app: FastifyInstance): Promise { return reply.code(404).send({ error: 'Server not found', statusCode: 404 }); } - if (!isOwner(id, request.userId)) { + if (!isServerOwner(id, request.userId)) { return reply.code(403).send({ error: 'Only the server owner can delete the server', statusCode: 403 }); } @@ -324,8 +336,8 @@ export async function serverRoutes(app: FastifyInstance): Promise { return reply.code(404).send({ error: 'Server not found', statusCode: 404 }); } - if (!isAdmin(id, request.userId)) { - return reply.code(403).send({ error: 'Only admins can generate invite codes', statusCode: 403 }); + 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 @@ -474,8 +486,8 @@ export async function serverRoutes(app: FastifyInstance): Promise { return reply.code(404).send({ error: 'Server not found', statusCode: 404 }); } - if (!isOwner(id, request.userId)) { - return reply.code(403).send({ error: 'Only the server owner can change member roles', statusCode: 403 }); + if (!hasPermission(request.userId, id, PermissionBits.MANAGE_ROLES)) { + return reply.code(403).send({ error: 'Missing MANAGE_ROLES permission', statusCode: 403 }); } if (uid === request.userId) { @@ -549,14 +561,15 @@ export async function serverRoutes(app: FastifyInstance): Promise { } const isSelf = uid === request.userId; - const isServerOwnerUser = isOwner(id, request.userId); + const isOwnerUser = isServerOwner(id, request.userId); + const canKick = hasPermission(request.userId, id, PermissionBits.KICK_MEMBERS); - if (!isSelf && !isServerOwnerUser) { - return reply.code(403).send({ error: 'Only the server owner can kick members', statusCode: 403 }); + 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 && isServerOwnerUser) { + if (isSelf && isOwnerUser) { return reply.code(400).send({ error: 'Server owner cannot leave. Transfer ownership or delete the server.', statusCode: 400 }); } @@ -604,8 +617,8 @@ export async function serverRoutes(app: FastifyInstance): Promise { const { name, color } = request.body; const db = getDb(); - if (!isAdmin(id, request.userId)) { - return reply.code(403).send({ error: 'Unauthorized', statusCode: 403 }); + if (!hasPermission(request.userId, id, PermissionBits.MANAGE_ROLES)) { + return reply.code(403).send({ error: 'Missing MANAGE_ROLES permission', statusCode: 403 }); } const roleId = generateSnowflake(); @@ -630,8 +643,8 @@ export async function serverRoutes(app: FastifyInstance): Promise { const updates = request.body; const db = getDb(); - if (!isAdmin(id, request.userId)) { - return reply.code(403).send({ error: 'Unauthorized', statusCode: 403 }); + if (!hasPermission(request.userId, id, PermissionBits.MANAGE_ROLES)) { + return reply.code(403).send({ error: 'Missing MANAGE_ROLES permission', statusCode: 403 }); } db.update(schema.roles).set(updates).where(and(eq(schema.roles.id, roleId), eq(schema.roles.serverId, id))).run(); @@ -646,10 +659,20 @@ export async function serverRoutes(app: FastifyInstance): Promise { const { id, roleId } = request.params; const db = getDb(); - if (!isAdmin(id, request.userId)) { - return reply.code(403).send({ error: 'Unauthorized', statusCode: 403 }); + 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.serverId, id))).run(); return reply.code(200).send({ success: true }); }); @@ -662,8 +685,8 @@ export async function serverRoutes(app: FastifyInstance): Promise { const { roleId } = request.body; const db = getDb(); - if (!isAdmin(id, request.userId)) { - return reply.code(403).send({ error: 'Unauthorized', statusCode: 403 }); + 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({ @@ -682,8 +705,8 @@ export async function serverRoutes(app: FastifyInstance): Promise { const { id, uid, roleId } = request.params; const db = getDb(); - if (!isAdmin(id, request.userId)) { - return reply.code(403).send({ error: 'Unauthorized', statusCode: 403 }); + 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( diff --git a/packages/server/src/utils/permissions.ts b/packages/server/src/utils/permissions.ts index 26df0734..fc45d920 100644 --- a/packages/server/src/utils/permissions.ts +++ b/packages/server/src/utils/permissions.ts @@ -1,6 +1,124 @@ import { eq, and } from 'drizzle-orm'; import { getDb, schema } from '../db/index.js'; import type { MemberRole } from '@opencord/shared'; +import { + PermissionBits, + ALL_PERMISSIONS, + stringToPermissions, + permissionsToString, +} from '@opencord/shared/src/permissions.js'; + +// Re-export for convenience +export { PermissionBits, ALL_PERMISSIONS, permissionsToString, stringToPermissions }; + +// ─── Core Resolution Engine ───────────────────────────────────────────────── + +/** + * Compute the effective permissions for a user in a server, optionally scoped + * to a specific channel. Follows Discord's resolution order: + * + * 1. Owner → ALL_PERMISSIONS + * 2. Base = @everyone.permissions | union of all assigned role permissions + * 3. ADMINISTRATOR in base → ALL_PERMISSIONS + * 4. If channelId provided, apply channel overrides in order: + * a. @everyone role override + * b. Role overrides (combined) + * c. Member-specific override + */ +export function computePermissions(userId: string, serverId: string, channelId?: string): bigint { + const db = getDb(); + + // 1. Owner check + const server = db.select().from(schema.servers).where(eq(schema.servers.id, serverId)).get(); + if (!server) return 0n; + if (server.ownerId === userId) return ALL_PERMISSIONS; + + // 2. Base permissions from @everyone role (id === serverId) + const everyoneRole = db.select().from(schema.roles) + .where(and(eq(schema.roles.id, serverId), eq(schema.roles.serverId, serverId))) + .get(); + let base = everyoneRole ? stringToPermissions(everyoneRole.permissions) : 0n; + + // Get user's assigned roles via member_roles + const memberRoleRows = db.select().from(schema.memberRoles) + .where(and( + eq(schema.memberRoles.serverId, serverId), + eq(schema.memberRoles.userId, userId), + )) + .all(); + + const assignedRoleIds = memberRoleRows.map(mr => mr.roleId); + + if (assignedRoleIds.length > 0) { + // Fetch all assigned roles and OR their permissions into base + for (const roleId of assignedRoleIds) { + const role = db.select().from(schema.roles).where(eq(schema.roles.id, roleId)).get(); + if (role) { + base |= stringToPermissions(role.permissions); + } + } + } + + // 3. Admin shortcut + if ((base & PermissionBits.ADMINISTRATOR) !== 0n) return ALL_PERMISSIONS; + + // If no channel, return server-level perms + if (!channelId) return base; + + // 4. Channel overrides + const overrides = db.select().from(schema.channelOverrides) + .where(eq(schema.channelOverrides.channelId, channelId)) + .all(); + + if (overrides.length === 0) return base; + + // 4a. @everyone role override (target_type='role', target_id=serverId) + const everyoneOverride = overrides.find(o => o.targetType === 'role' && o.targetId === serverId); + if (everyoneOverride) { + const deny = stringToPermissions(everyoneOverride.deny); + const allow = stringToPermissions(everyoneOverride.allow); + base = (base & ~deny) | allow; + } + + // 4b. Role overrides (combined for all assigned roles) + let combinedAllow = 0n; + let combinedDeny = 0n; + for (const roleId of assignedRoleIds) { + const roleOverride = overrides.find(o => o.targetType === 'role' && o.targetId === roleId); + if (roleOverride) { + combinedAllow |= stringToPermissions(roleOverride.allow); + combinedDeny |= stringToPermissions(roleOverride.deny); + } + } + base = (base & ~combinedDeny) | combinedAllow; + + // 4c. Member-specific override + const memberOverride = overrides.find(o => o.targetType === 'member' && o.targetId === userId); + if (memberOverride) { + const deny = stringToPermissions(memberOverride.deny); + const allow = stringToPermissions(memberOverride.allow); + base = (base & ~deny) | allow; + } + + return base; +} + +/** + * Check if a user has a specific permission in a server/channel. + */ +export function hasPermission( + userId: string, + serverId: string, + permission: bigint, + channelId?: string, +): boolean { + const perms = computePermissions(userId, serverId, channelId); + // ADMINISTRATOR grants everything + if ((perms & PermissionBits.ADMINISTRATOR) !== 0n) return true; + return (perms & permission) === permission; +} + +// ─── Existing helpers (kept for backward compat) ──────────────────────────── export function getMember(serverId: string, userId: string) { const db = getDb(); @@ -21,16 +139,6 @@ export function getMemberRole(serverId: string, userId: string): MemberRole | nu return member ? (member.role as MemberRole) : null; } -export function isOwner(serverId: string, userId: string): boolean { - const role = getMemberRole(serverId, userId); - return role === 'owner'; -} - -export function isAdmin(serverId: string, userId: string): boolean { - const role = getMemberRole(serverId, userId); - return role === 'owner' || role === 'admin'; -} - export function isServerOwner(serverId: string, userId: string): boolean { const db = getDb(); const server = db.select().from(schema.servers).where(eq(schema.servers.id, serverId)).get(); diff --git a/packages/server/src/ws/events.ts b/packages/server/src/ws/events.ts index 151cba24..0470211f 100644 --- a/packages/server/src/ws/events.ts +++ b/packages/server/src/ws/events.ts @@ -3,7 +3,7 @@ import { getDb, schema } from '../db/index.js'; import { generateSnowflake } from '../utils/snowflake.js'; import { connectionManager } from './handler.js'; import type { VoiceRoom, DmRoomMeta, ServerRoomMeta } from './handler.js'; -import { isMember, getChannelServerId, isDmMember } from '../utils/permissions.js'; +import { isMember, getChannelServerId, isDmMember, hasPermission, PermissionBits } from '../utils/permissions.js'; import { broadcastDmMessage, getDmMessageWithUser } from '../routes/dm.js'; import type { User, MessageWithUser, Attachment, DmMessageWithUser } from '@opencord/shared'; @@ -190,8 +190,8 @@ function handleMessageCreate(event: Record, userId: string): vo return; } - if (!isMember(serverId, userId)) { - connectionManager.sendToUser(userId, { type: 'error', message: 'Not a member of this server' }); + if (!hasPermission(userId, serverId, PermissionBits.SEND_MESSAGES, channelId)) { + connectionManager.sendToUser(userId, { type: 'error', message: 'Missing SEND_MESSAGES permission' }); return; } @@ -280,17 +280,11 @@ function handleMessageDelete(event: Record, userId: string): vo const serverId = getChannelServerId(message.channelId); if (!serverId) return; - // Allow author or admin to delete + // Allow author or MANAGE_MESSAGES permission holder to delete const isAuthor = message.userId === userId; - const memberRow = db.select() - .from(schema.serverMembers) - .where(eq(schema.serverMembers.serverId, serverId)) - .all() - .find(m => m.userId === userId); + const canManageMessages = hasPermission(userId, serverId, PermissionBits.MANAGE_MESSAGES, message.channelId); - const isAdminRole = memberRow?.role === 'admin' || memberRow?.role === 'owner'; - - if (!isAuthor && !isAdminRole) { + if (!isAuthor && !canManageMessages) { connectionManager.sendToUser(userId, { type: 'error', message: 'You cannot delete this message' }); return; } @@ -314,7 +308,7 @@ function handleTypingStart(event: Record, userId: string, usern const serverId = getChannelServerId(channelId); if (!serverId) return; - if (!isMember(serverId, userId)) return; + if (!hasPermission(userId, serverId, PermissionBits.SEND_MESSAGES, channelId)) return; // Clear previous typing timeout for this user+channel const key = `${userId}:${channelId}`; @@ -412,8 +406,8 @@ function handleVoiceJoin(event: Record, userId: string): void { return; } - if (!isMember(serverId, userId)) { - connectionManager.sendToUser(userId, { type: 'error', message: 'Not a member of this server' }); + if (!hasPermission(userId, serverId, PermissionBits.CONNECT, channelId)) { + connectionManager.sendToUser(userId, { type: 'error', message: 'Missing CONNECT permission' }); return; } diff --git a/packages/server/src/ws/handler.ts b/packages/server/src/ws/handler.ts index 1ec748d6..86fd3fa4 100644 --- a/packages/server/src/ws/handler.ts +++ b/packages/server/src/ws/handler.ts @@ -4,6 +4,7 @@ import { verifyJwt } from '../utils/auth.js'; import { getDb, schema } from '../db/index.js'; import { eq, inArray, desc, sql } from 'drizzle-orm'; import { handleClientEvent } from './events.js'; +import { computePermissions, PermissionBits, permissionsToString } from '../utils/permissions.js'; import type { User, ServerWithChannelsAndMembers, @@ -622,6 +623,29 @@ function buildReadyPayload(userId: string): { }) .filter((m): m is MemberWithUser => m !== null); + // Compute server-level permissions for this user + const serverPerms = computePermissions(userId, serverRow.id); + + // Filter channels by VIEW_CHANNEL and attach per-channel permissions + const visibleChannels: Channel[] = []; + for (const ch of channels) { + const chPerms = computePermissions(userId, serverRow.id, ch.id); + const hasView = (chPerms & PermissionBits.VIEW_CHANNEL) !== 0n || (chPerms & PermissionBits.ADMINISTRATOR) !== 0n; + if (hasView) { + visibleChannels.push({ + id: ch.id, + serverId: ch.serverId, + name: ch.name, + type: ch.type as Channel['type'], + topic: ch.topic, + position: ch.position ?? 0, + createdAt: ch.createdAt, + lastMessageId: lastMsgMap.get(ch.id) ?? null, + myPermissions: permissionsToString(chPerms), + }); + } + } + servers.push({ id: serverRow.id, name: serverRow.name, @@ -629,16 +653,7 @@ function buildReadyPayload(userId: string): { ownerId: serverRow.ownerId, inviteCode: serverRow.inviteCode, createdAt: serverRow.createdAt, - channels: channels.map(ch => ({ - id: ch.id, - serverId: ch.serverId, - name: ch.name, - type: ch.type as Channel['type'], - topic: ch.topic, - position: ch.position ?? 0, - createdAt: ch.createdAt, - lastMessageId: lastMsgMap.get(ch.id) ?? null, - })), + channels: visibleChannels, members, roles: roles.map(r => ({ id: r.id, @@ -646,8 +661,11 @@ function buildReadyPayload(userId: string): { name: r.name, color: r.color ?? '#b9bbbe', position: r.position ?? 0, + permissions: r.permissions ?? undefined, + isEveryone: r.id === serverRow.id, createdAt: r.createdAt, })), + myPermissions: permissionsToString(serverPerms), }); } } diff --git a/packages/shared/package.json b/packages/shared/package.json index cf03ae82..adf0d279 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -6,7 +6,9 @@ "main": "./src/types.ts", "types": "./src/types.ts", "exports": { - ".": "./src/types.ts" + ".": "./src/types.ts", + "./src/permissions": "./src/permissions.ts", + "./src/permissions.js": "./src/permissions.ts" }, "scripts": { "build": "tsc", diff --git a/packages/shared/src/permissions.ts b/packages/shared/src/permissions.ts new file mode 100644 index 00000000..812c09b8 --- /dev/null +++ b/packages/shared/src/permissions.ts @@ -0,0 +1,68 @@ +// ─── Bitwise Permission Engine ────────────────────────────────────────────── +// Single source of truth for all permission bits. Used by both server and client. +// SQLite stores as TEXT (decimal string). Never put raw bigint into JSON. + +export const PermissionBits = { + ADMINISTRATOR: 1n << 0n, + VIEW_CHANNEL: 1n << 1n, + MANAGE_CHANNELS: 1n << 2n, + MANAGE_ROLES: 1n << 3n, + MANAGE_SERVER: 1n << 4n, + CREATE_INVITE: 1n << 5n, + KICK_MEMBERS: 1n << 6n, + BAN_MEMBERS: 1n << 7n, + SEND_MESSAGES: 1n << 10n, + MANAGE_MESSAGES: 1n << 11n, + ATTACH_FILES: 1n << 12n, + READ_MESSAGE_HISTORY: 1n << 13n, + ADD_REACTIONS: 1n << 14n, + CONNECT: 1n << 20n, + SPEAK: 1n << 21n, + MUTE_MEMBERS: 1n << 22n, + DEAFEN_MEMBERS: 1n << 23n, + MOVE_MEMBERS: 1n << 24n, + USE_VOICE_ACTIVITY: 1n << 25n, + STREAM: 1n << 26n, +} as const; + +export type PermissionBit = (typeof PermissionBits)[keyof typeof PermissionBits]; + +export const ALL_PERMISSIONS = Object.values(PermissionBits).reduce((a, b) => a | b, 0n); + +export const DEFAULT_EVERYONE_PERMISSIONS = + PermissionBits.VIEW_CHANNEL | + PermissionBits.SEND_MESSAGES | + PermissionBits.CREATE_INVITE | + PermissionBits.CONNECT | + PermissionBits.SPEAK | + PermissionBits.ATTACH_FILES | + PermissionBits.READ_MESSAGE_HISTORY | + PermissionBits.ADD_REACTIONS | + PermissionBits.STREAM | + PermissionBits.USE_VOICE_ACTIVITY; + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +/** Check if a permissions value has a specific bit set. Accepts bigint or decimal string. */ +export function hasPermissionBit(perms: bigint | string | undefined | null, bit: bigint): boolean { + if (perms === undefined || perms === null) return false; + const p = typeof perms === 'string' ? BigInt(perms) : perms; + // ADMINISTRATOR grants everything + if ((p & PermissionBits.ADMINISTRATOR) !== 0n) return true; + return (p & bit) === bit; +} + +/** Convert a bigint to a decimal string safe for JSON serialization. */ +export function permissionsToString(perms: bigint): string { + return perms.toString(); +} + +/** Convert a decimal string back to bigint. Returns 0n for falsy/invalid input. */ +export function stringToPermissions(str: string | undefined | null): bigint { + if (!str) return 0n; + try { + return BigInt(str); + } catch { + return 0n; + } +} diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 5c404839..77783076 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -31,6 +31,7 @@ export interface ServerWithChannelsAndMembers extends Server { channels: Channel[]; members: MemberWithUser[]; roles: Role[]; + myPermissions?: string; // Computed per-user BigInt decimal string (server-level) } // ─── Member Types ─────────────────────────────────────────────────────────── @@ -58,7 +59,8 @@ export interface Role { name: string; color: string; position: number; - permissions?: string[]; + permissions?: string; // BigInt decimal string (bitwise) + isEveryone?: boolean; // UI hint: true when role.id === server.id createdAt: number; } @@ -86,6 +88,7 @@ export interface Channel { position: number; createdAt: number; lastMessageId?: string | null; + myPermissions?: string; // Computed per-user BigInt decimal string } export interface ReadState { diff --git a/packages/web/src/components/chat/Message.tsx b/packages/web/src/components/chat/Message.tsx index a807cb14..ee19edc3 100644 --- a/packages/web/src/components/chat/Message.tsx +++ b/packages/web/src/components/chat/Message.tsx @@ -8,6 +8,7 @@ import { useChatStore } from '../../stores/chatStore'; import { useServerStore } from '../../stores/serverStore'; import { useUIStore } from '../../stores/uiStore'; import { Embed } from './Embed'; +import { hasPermissionBit, PermissionBits } from '../../utils/permissions'; interface MessageProps { message: MessageWithUser; @@ -47,9 +48,10 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) { const channelKey = message.channelId || (message as any).dmChannelId; const isAuthor = currentUser?.id === message.userId; - const memberRole = members.find(m => m.userId === currentUser?.id)?.role; - const isAdminUser = memberRole === 'admin' || memberRole === 'owner'; - const canDelete = isAuthor || isAdminUser; + const channelPermissions = useServerStore((s) => s.channelPermissions); + const myChPerms = channelPermissions.get(message.channelId); + const canManageMessages = hasPermissionBit(myChPerms, PermissionBits.MANAGE_MESSAGES); + const canDelete = isAuthor || canManageMessages; const addReaction = useChatStore((s) => s.addReaction); const removeReaction = useChatStore((s) => s.removeReaction); diff --git a/packages/web/src/components/layout/ChannelSidebar.tsx b/packages/web/src/components/layout/ChannelSidebar.tsx index 401c778e..a33397f9 100644 --- a/packages/web/src/components/layout/ChannelSidebar.tsx +++ b/packages/web/src/components/layout/ChannelSidebar.tsx @@ -11,6 +11,7 @@ import { Avatar } from '../ui/Avatar'; import { wsSend } from '../../hooks/useWebSocket'; import { getActiveRoom } from '../../hooks/useLiveKit'; import { AudioManager } from '../../audio/AudioManager'; +import { hasPermissionBit, PermissionBits } from '../../utils/permissions'; export function ChannelSidebar() { const servers = useServerStore((s) => s.servers); @@ -65,9 +66,10 @@ export function ChannelSidebar() { } }; + const serverPermissions = useServerStore((s) => s.serverPermissions); const server = servers.find(s => s.id === currentServerId); - const currentMember = members.find(m => m.userId === user?.id); - const isAdminUser = currentMember?.role === 'admin' || currentMember?.role === 'owner'; + const myServerPerms = currentServerId ? serverPermissions.get(currentServerId) : undefined; + const canManageChannels = hasPermissionBit(myServerPerms, PermissionBits.MANAGE_CHANNELS); const textChannels = channels.filter(c => c.type === 'text'); const voiceChannels = channels.filter(c => c.type === 'voice' || c.type === 'video'); @@ -306,7 +308,7 @@ export function ChannelSidebar() { Text Channels - {isAdminUser && ( + {canManageChannels && (