feat: bitwise RBAC engine with channel-level permission overrides
Replace string-based role checks (role === 'admin') with a bitwise BigInt permission system. Adds computePermissions() resolution engine following Discord's model: @everyone base → role union → admin shortcut → channel overrides (role deny/allow → member deny/allow). Ready payload now filters channels by VIEW_CHANNEL and attaches per-user myPermissions to each server and channel. Includes channel_overrides table, @everyone role auto-creation, migration for existing servers, and override CRUD API.
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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<string, string[]>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<void> {
|
||||
const db = getDb();
|
||||
@@ -63,6 +64,17 @@ export async function seedDatabase(): Promise<void> {
|
||||
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`);
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
}
|
||||
|
||||
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<typeof schema.channels.$inferInsert> = {};
|
||||
@@ -194,8 +200,8 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
|
||||
}
|
||||
|
||||
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<void> {
|
||||
|
||||
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 });
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
@@ -30,8 +30,8 @@ export async function livekitRoutes(app: FastifyInstance): Promise<void> {
|
||||
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 {
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<typeof schema.servers.$inferInsert> = {};
|
||||
@@ -298,7 +310,7 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
}
|
||||
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user