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:
Jannis Braun
2026-02-24 05:08:59 +01:00
parent 024833c470
commit 8030c89c6c
19 changed files with 568 additions and 93 deletions
+9
View File
@@ -144,6 +144,15 @@ function createTables(db: Database.Database): void {
PRIMARY KEY (server_id, user_id, role_id) 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 ( CREATE TABLE IF NOT EXISTS read_states (
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
channel_id TEXT NOT NULL, channel_id TEXT NOT NULL,
+70
View File
@@ -1,4 +1,5 @@
import Database from 'better-sqlite3'; 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 { export function runMigrations(db: Database.Database): void {
console.log('Checking for database migrations...'); console.log('Checking for database migrations...');
@@ -66,5 +67,74 @@ 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.'); 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);
}
}
}
}
+10
View File
@@ -140,6 +140,16 @@ export const memberRoles = sqliteTable('member_roles', {
pk: primaryKey({ columns: [table.serverId, table.userId, table.roleId] }), 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', { export const readStates = sqliteTable('read_states', {
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
channelId: text('channel_id').notNull(), channelId: text('channel_id').notNull(),
+12
View File
@@ -2,6 +2,7 @@ import { getDb, schema } from './index.js';
import { generateSnowflake } from '../utils/snowflake.js'; import { generateSnowflake } from '../utils/snowflake.js';
import { hashPassword } from '../utils/auth.js'; import { hashPassword } from '../utils/auth.js';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { DEFAULT_EVERYONE_PERMISSIONS, permissionsToString } from '@opencord/shared/src/permissions.js';
export async function seedDatabase(): Promise<void> { export async function seedDatabase(): Promise<void> {
const db = getDb(); const db = getDb();
@@ -63,6 +64,17 @@ export async function seedDatabase(): Promise<void> {
createdAt: Date.now(), createdAt: Date.now(),
}).run(); }).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('Database seeded successfully');
console.log(` Default server: Opencord (invite code: opencord)`); console.log(` Default server: Opencord (invite code: opencord)`);
console.log(` Admin user: admin / admin123`); console.log(` Admin user: admin / admin123`);
+135 -12
View File
@@ -1,9 +1,9 @@
import type { FastifyInstance } from 'fastify'; import type { FastifyInstance } from 'fastify';
import { eq } from 'drizzle-orm'; import { eq, and } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js'; import { getDb, schema } from '../db/index.js';
import { authenticate } from '../utils/auth.js'; import { authenticate } from '../utils/auth.js';
import { generateSnowflake } from '../utils/snowflake.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 { connectionManager } from '../ws/handler.js';
import type { import type {
CreateChannelRequest, 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 }); 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) .from(schema.channels)
.where(eq(schema.channels.serverId, id)) .where(eq(schema.channels.serverId, id))
.all(); .all();
// Sort by position // Filter by VIEW_CHANNEL permission per channel
channels.sort((a, b) => (a.position ?? 0) - (b.position ?? 0)); 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+) // 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 }); return reply.code(404).send({ error: 'Server not found', statusCode: 404 });
} }
if (!isAdmin(id, request.userId)) { if (!hasPermission(request.userId, id, PermissionBits.MANAGE_CHANNELS)) {
return reply.code(403).send({ error: 'Only admins can create channels', statusCode: 403 }); return reply.code(403).send({ error: 'Missing MANAGE_CHANNELS permission', statusCode: 403 });
} }
if (!name || typeof name !== 'string') { if (!name || typeof name !== 'string') {
@@ -133,8 +139,8 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
} }
const serverId = channel.serverId; const serverId = channel.serverId;
if (!isAdmin(serverId, request.userId)) { if (!hasPermission(request.userId, serverId, PermissionBits.MANAGE_CHANNELS, id)) {
return reply.code(403).send({ error: 'Only admins can update channels', statusCode: 403 }); return reply.code(403).send({ error: 'Missing MANAGE_CHANNELS permission', statusCode: 403 });
} }
const updates: Partial<typeof schema.channels.$inferInsert> = {}; const updates: Partial<typeof schema.channels.$inferInsert> = {};
@@ -194,8 +200,8 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
} }
const serverId = channel.serverId; const serverId = channel.serverId;
if (!isAdmin(serverId, request.userId)) { if (!hasPermission(request.userId, serverId, PermissionBits.MANAGE_CHANNELS, id)) {
return reply.code(403).send({ error: 'Only admins can delete channels', statusCode: 403 }); return reply.code(403).send({ error: 'Missing MANAGE_CHANNELS permission', statusCode: 403 });
} }
// Delete messages in channel (attachments cascade), then channel // 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 }); 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 });
},
);
} }
+3 -3
View File
@@ -2,7 +2,7 @@ import type { FastifyInstance } from 'fastify';
import { AccessToken } from 'livekit-server-sdk'; import { AccessToken } from 'livekit-server-sdk';
import { authenticate } from '../utils/auth.js'; import { authenticate } from '../utils/auth.js';
import { config } from '../config.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'; import type { LiveKitTokenRequest, LiveKitTokenResponse } from '@opencord/shared';
export async function livekitRoutes(app: FastifyInstance): Promise<void> { export async function livekitRoutes(app: FastifyInstance): Promise<void> {
@@ -30,8 +30,8 @@ export async function livekitRoutes(app: FastifyInstance): Promise<void> {
if (!serverId) { if (!serverId) {
return reply.code(404).send({ error: 'Channel not found', statusCode: 404 }); return reply.code(404).send({ error: 'Channel not found', statusCode: 404 });
} }
if (!isMember(serverId, request.userId)) { if (!hasPermission(request.userId, serverId, PermissionBits.CONNECT, channelId)) {
return reply.code(403).send({ error: 'You are not a member of this server', statusCode: 403 }); return reply.code(403).send({ error: 'Missing CONNECT permission', statusCode: 403 });
} }
roomName = channelId; roomName = channelId;
} else { } else {
+7 -7
View File
@@ -3,7 +3,7 @@ import { eq, and, desc, lt, inArray } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js'; import { getDb, schema } from '../db/index.js';
import { authenticate } from '../utils/auth.js'; import { authenticate } from '../utils/auth.js';
import { generateSnowflake } from '../utils/snowflake.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 { connectionManager } from '../ws/handler.js';
import type { import type {
CreateMessageRequest, CreateMessageRequest,
@@ -175,8 +175,8 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
return reply.code(404).send({ error: 'Channel not found', statusCode: 404 }); return reply.code(404).send({ error: 'Channel not found', statusCode: 404 });
} }
if (!isMember(serverId, request.userId)) { if (!hasPermission(request.userId, serverId, PermissionBits.VIEW_CHANNEL | PermissionBits.READ_MESSAGE_HISTORY, id)) {
return reply.code(403).send({ error: 'You are not a member of this server', statusCode: 403 }); return reply.code(403).send({ error: 'Missing VIEW_CHANNEL or READ_MESSAGE_HISTORY permission', statusCode: 403 });
} }
const db = getDb(); 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 }); return reply.code(404).send({ error: 'Channel not found', statusCode: 404 });
} }
if (!isMember(serverId, request.userId)) { if (!hasPermission(request.userId, serverId, PermissionBits.SEND_MESSAGES, id)) {
return reply.code(403).send({ error: 'You are not a member of this server', statusCode: 403 }); return reply.code(403).send({ error: 'Missing SEND_MESSAGES permission', statusCode: 403 });
} }
if ((!content || typeof content !== 'string' || content.trim().length === 0) && 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 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 }); return reply.code(403).send({ error: 'You cannot delete this message', statusCode: 403 });
} }
+46 -23
View File
@@ -3,7 +3,8 @@ import { eq, and, inArray } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js'; import { getDb, schema } from '../db/index.js';
import { authenticate } from '../utils/auth.js'; import { authenticate } from '../utils/auth.js';
import { generateSnowflake } from '../utils/snowflake.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 crypto from 'crypto';
import { connectionManager } from '../ws/handler.js'; import { connectionManager } from '../ws/handler.js';
import type { import type {
@@ -80,7 +81,7 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
const now = Date.now(); const now = Date.now();
const inviteCode = generateInviteCode(); const inviteCode = generateInviteCode();
// Create server, owner membership, and default channel atomically // Create server, owner membership, default channel, and @everyone role atomically
db.transaction((tx) => { db.transaction((tx) => {
tx.insert(schema.servers).values({ tx.insert(schema.servers).values({
id: serverId, id: serverId,
@@ -106,6 +107,17 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
position: 0, position: 0,
createdAt: now, createdAt: now,
}).run(); }).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(); 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 }); return reply.code(404).send({ error: 'Server not found', statusCode: 404 });
} }
if (!isOwner(id, request.userId)) { if (!hasPermission(request.userId, id, PermissionBits.MANAGE_SERVER)) {
return reply.code(403).send({ error: 'Only the server owner can update the server', statusCode: 403 }); return reply.code(403).send({ error: 'Missing MANAGE_SERVER permission', statusCode: 403 });
} }
const updates: Partial<typeof schema.servers.$inferInsert> = {}; 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 }); 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 }); 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 }); return reply.code(404).send({ error: 'Server not found', statusCode: 404 });
} }
if (!isAdmin(id, request.userId)) { if (!hasPermission(request.userId, id, PermissionBits.CREATE_INVITE)) {
return reply.code(403).send({ error: 'Only admins can generate invite codes', statusCode: 403 }); return reply.code(403).send({ error: 'Missing CREATE_INVITE permission', statusCode: 403 });
} }
// Return existing invite code if one exists, otherwise generate a new one // 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 }); return reply.code(404).send({ error: 'Server not found', statusCode: 404 });
} }
if (!isOwner(id, request.userId)) { if (!hasPermission(request.userId, id, PermissionBits.MANAGE_ROLES)) {
return reply.code(403).send({ error: 'Only the server owner can change member roles', statusCode: 403 }); return reply.code(403).send({ error: 'Missing MANAGE_ROLES permission', statusCode: 403 });
} }
if (uid === request.userId) { if (uid === request.userId) {
@@ -549,14 +561,15 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
} }
const isSelf = uid === request.userId; 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) { if (!isSelf && !canKick) {
return reply.code(403).send({ error: 'Only the server owner can kick members', statusCode: 403 }); return reply.code(403).send({ error: 'Missing KICK_MEMBERS permission', statusCode: 403 });
} }
// Owner cannot leave their own server - they must delete it // 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 }); 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 { name, color } = request.body;
const db = getDb(); const db = getDb();
if (!isAdmin(id, request.userId)) { if (!hasPermission(request.userId, id, PermissionBits.MANAGE_ROLES)) {
return reply.code(403).send({ error: 'Unauthorized', statusCode: 403 }); return reply.code(403).send({ error: 'Missing MANAGE_ROLES permission', statusCode: 403 });
} }
const roleId = generateSnowflake(); const roleId = generateSnowflake();
@@ -630,8 +643,8 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
const updates = request.body; const updates = request.body;
const db = getDb(); const db = getDb();
if (!isAdmin(id, request.userId)) { if (!hasPermission(request.userId, id, PermissionBits.MANAGE_ROLES)) {
return reply.code(403).send({ error: 'Unauthorized', statusCode: 403 }); 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(); 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 { id, roleId } = request.params;
const db = getDb(); const db = getDb();
if (!isAdmin(id, request.userId)) { if (!hasPermission(request.userId, id, PermissionBits.MANAGE_ROLES)) {
return reply.code(403).send({ error: 'Unauthorized', statusCode: 403 }); 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(); db.delete(schema.roles).where(and(eq(schema.roles.id, roleId), eq(schema.roles.serverId, id))).run();
return reply.code(200).send({ success: true }); return reply.code(200).send({ success: true });
}); });
@@ -662,8 +685,8 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
const { roleId } = request.body; const { roleId } = request.body;
const db = getDb(); const db = getDb();
if (!isAdmin(id, request.userId)) { if (!hasPermission(request.userId, id, PermissionBits.MANAGE_ROLES)) {
return reply.code(403).send({ error: 'Unauthorized', statusCode: 403 }); return reply.code(403).send({ error: 'Missing MANAGE_ROLES permission', statusCode: 403 });
} }
db.insert(schema.memberRoles).values({ db.insert(schema.memberRoles).values({
@@ -682,8 +705,8 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
const { id, uid, roleId } = request.params; const { id, uid, roleId } = request.params;
const db = getDb(); const db = getDb();
if (!isAdmin(id, request.userId)) { if (!hasPermission(request.userId, id, PermissionBits.MANAGE_ROLES)) {
return reply.code(403).send({ error: 'Unauthorized', statusCode: 403 }); return reply.code(403).send({ error: 'Missing MANAGE_ROLES permission', statusCode: 403 });
} }
db.delete(schema.memberRoles).where(and( db.delete(schema.memberRoles).where(and(
+118 -10
View File
@@ -1,6 +1,124 @@
import { eq, and } from 'drizzle-orm'; import { eq, and } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js'; import { getDb, schema } from '../db/index.js';
import type { MemberRole } from '@opencord/shared'; 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) { export function getMember(serverId: string, userId: string) {
const db = getDb(); const db = getDb();
@@ -21,16 +139,6 @@ export function getMemberRole(serverId: string, userId: string): MemberRole | nu
return member ? (member.role as MemberRole) : null; 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 { export function isServerOwner(serverId: string, userId: string): boolean {
const db = getDb(); const db = getDb();
const server = db.select().from(schema.servers).where(eq(schema.servers.id, serverId)).get(); const server = db.select().from(schema.servers).where(eq(schema.servers.id, serverId)).get();
+9 -15
View File
@@ -3,7 +3,7 @@ import { getDb, schema } from '../db/index.js';
import { generateSnowflake } from '../utils/snowflake.js'; import { generateSnowflake } from '../utils/snowflake.js';
import { connectionManager } from './handler.js'; import { connectionManager } from './handler.js';
import type { VoiceRoom, DmRoomMeta, ServerRoomMeta } 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 { broadcastDmMessage, getDmMessageWithUser } from '../routes/dm.js';
import type { User, MessageWithUser, Attachment, DmMessageWithUser } from '@opencord/shared'; import type { User, MessageWithUser, Attachment, DmMessageWithUser } from '@opencord/shared';
@@ -190,8 +190,8 @@ function handleMessageCreate(event: Record<string, unknown>, userId: string): vo
return; return;
} }
if (!isMember(serverId, userId)) { if (!hasPermission(userId, serverId, PermissionBits.SEND_MESSAGES, channelId)) {
connectionManager.sendToUser(userId, { type: 'error', message: 'Not a member of this server' }); connectionManager.sendToUser(userId, { type: 'error', message: 'Missing SEND_MESSAGES permission' });
return; return;
} }
@@ -280,17 +280,11 @@ function handleMessageDelete(event: Record<string, unknown>, userId: string): vo
const serverId = getChannelServerId(message.channelId); const serverId = getChannelServerId(message.channelId);
if (!serverId) return; if (!serverId) return;
// Allow author or admin to delete // Allow author or MANAGE_MESSAGES permission holder to delete
const isAuthor = message.userId === userId; const isAuthor = message.userId === userId;
const memberRow = db.select() const canManageMessages = hasPermission(userId, serverId, PermissionBits.MANAGE_MESSAGES, message.channelId);
.from(schema.serverMembers)
.where(eq(schema.serverMembers.serverId, serverId))
.all()
.find(m => m.userId === userId);
const isAdminRole = memberRow?.role === 'admin' || memberRow?.role === 'owner'; if (!isAuthor && !canManageMessages) {
if (!isAuthor && !isAdminRole) {
connectionManager.sendToUser(userId, { type: 'error', message: 'You cannot delete this message' }); connectionManager.sendToUser(userId, { type: 'error', message: 'You cannot delete this message' });
return; return;
} }
@@ -314,7 +308,7 @@ function handleTypingStart(event: Record<string, unknown>, userId: string, usern
const serverId = getChannelServerId(channelId); const serverId = getChannelServerId(channelId);
if (!serverId) return; 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 // Clear previous typing timeout for this user+channel
const key = `${userId}:${channelId}`; const key = `${userId}:${channelId}`;
@@ -412,8 +406,8 @@ function handleVoiceJoin(event: Record<string, unknown>, userId: string): void {
return; return;
} }
if (!isMember(serverId, userId)) { if (!hasPermission(userId, serverId, PermissionBits.CONNECT, channelId)) {
connectionManager.sendToUser(userId, { type: 'error', message: 'Not a member of this server' }); connectionManager.sendToUser(userId, { type: 'error', message: 'Missing CONNECT permission' });
return; return;
} }
+28 -10
View File
@@ -4,6 +4,7 @@ import { verifyJwt } from '../utils/auth.js';
import { getDb, schema } from '../db/index.js'; import { getDb, schema } from '../db/index.js';
import { eq, inArray, desc, sql } from 'drizzle-orm'; import { eq, inArray, desc, sql } from 'drizzle-orm';
import { handleClientEvent } from './events.js'; import { handleClientEvent } from './events.js';
import { computePermissions, PermissionBits, permissionsToString } from '../utils/permissions.js';
import type { import type {
User, User,
ServerWithChannelsAndMembers, ServerWithChannelsAndMembers,
@@ -622,6 +623,29 @@ function buildReadyPayload(userId: string): {
}) })
.filter((m): m is MemberWithUser => m !== null); .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({ servers.push({
id: serverRow.id, id: serverRow.id,
name: serverRow.name, name: serverRow.name,
@@ -629,16 +653,7 @@ function buildReadyPayload(userId: string): {
ownerId: serverRow.ownerId, ownerId: serverRow.ownerId,
inviteCode: serverRow.inviteCode, inviteCode: serverRow.inviteCode,
createdAt: serverRow.createdAt, createdAt: serverRow.createdAt,
channels: channels.map(ch => ({ channels: visibleChannels,
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,
})),
members, members,
roles: roles.map(r => ({ roles: roles.map(r => ({
id: r.id, id: r.id,
@@ -646,8 +661,11 @@ function buildReadyPayload(userId: string): {
name: r.name, name: r.name,
color: r.color ?? '#b9bbbe', color: r.color ?? '#b9bbbe',
position: r.position ?? 0, position: r.position ?? 0,
permissions: r.permissions ?? undefined,
isEveryone: r.id === serverRow.id,
createdAt: r.createdAt, createdAt: r.createdAt,
})), })),
myPermissions: permissionsToString(serverPerms),
}); });
} }
} }
+3 -1
View File
@@ -6,7 +6,9 @@
"main": "./src/types.ts", "main": "./src/types.ts",
"types": "./src/types.ts", "types": "./src/types.ts",
"exports": { "exports": {
".": "./src/types.ts" ".": "./src/types.ts",
"./src/permissions": "./src/permissions.ts",
"./src/permissions.js": "./src/permissions.ts"
}, },
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",
+68
View File
@@ -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;
}
}
+4 -1
View File
@@ -31,6 +31,7 @@ export interface ServerWithChannelsAndMembers extends Server {
channels: Channel[]; channels: Channel[];
members: MemberWithUser[]; members: MemberWithUser[];
roles: Role[]; roles: Role[];
myPermissions?: string; // Computed per-user BigInt decimal string (server-level)
} }
// ─── Member Types ─────────────────────────────────────────────────────────── // ─── Member Types ───────────────────────────────────────────────────────────
@@ -58,7 +59,8 @@ export interface Role {
name: string; name: string;
color: string; color: string;
position: number; position: number;
permissions?: string[]; permissions?: string; // BigInt decimal string (bitwise)
isEveryone?: boolean; // UI hint: true when role.id === server.id
createdAt: number; createdAt: number;
} }
@@ -86,6 +88,7 @@ export interface Channel {
position: number; position: number;
createdAt: number; createdAt: number;
lastMessageId?: string | null; lastMessageId?: string | null;
myPermissions?: string; // Computed per-user BigInt decimal string
} }
export interface ReadState { export interface ReadState {
+5 -3
View File
@@ -8,6 +8,7 @@ import { useChatStore } from '../../stores/chatStore';
import { useServerStore } from '../../stores/serverStore'; import { useServerStore } from '../../stores/serverStore';
import { useUIStore } from '../../stores/uiStore'; import { useUIStore } from '../../stores/uiStore';
import { Embed } from './Embed'; import { Embed } from './Embed';
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
interface MessageProps { interface MessageProps {
message: MessageWithUser; message: MessageWithUser;
@@ -47,9 +48,10 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
const channelKey = message.channelId || (message as any).dmChannelId; const channelKey = message.channelId || (message as any).dmChannelId;
const isAuthor = currentUser?.id === message.userId; const isAuthor = currentUser?.id === message.userId;
const memberRole = members.find(m => m.userId === currentUser?.id)?.role; const channelPermissions = useServerStore((s) => s.channelPermissions);
const isAdminUser = memberRole === 'admin' || memberRole === 'owner'; const myChPerms = channelPermissions.get(message.channelId);
const canDelete = isAuthor || isAdminUser; const canManageMessages = hasPermissionBit(myChPerms, PermissionBits.MANAGE_MESSAGES);
const canDelete = isAuthor || canManageMessages;
const addReaction = useChatStore((s) => s.addReaction); const addReaction = useChatStore((s) => s.addReaction);
const removeReaction = useChatStore((s) => s.removeReaction); const removeReaction = useChatStore((s) => s.removeReaction);
@@ -11,6 +11,7 @@ import { Avatar } from '../ui/Avatar';
import { wsSend } from '../../hooks/useWebSocket'; import { wsSend } from '../../hooks/useWebSocket';
import { getActiveRoom } from '../../hooks/useLiveKit'; import { getActiveRoom } from '../../hooks/useLiveKit';
import { AudioManager } from '../../audio/AudioManager'; import { AudioManager } from '../../audio/AudioManager';
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
export function ChannelSidebar() { export function ChannelSidebar() {
const servers = useServerStore((s) => s.servers); 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 server = servers.find(s => s.id === currentServerId);
const currentMember = members.find(m => m.userId === user?.id); const myServerPerms = currentServerId ? serverPermissions.get(currentServerId) : undefined;
const isAdminUser = currentMember?.role === 'admin' || currentMember?.role === 'owner'; const canManageChannels = hasPermissionBit(myServerPerms, PermissionBits.MANAGE_CHANNELS);
const textChannels = channels.filter(c => c.type === 'text'); const textChannels = channels.filter(c => c.type === 'text');
const voiceChannels = channels.filter(c => c.type === 'voice' || c.type === 'video'); const voiceChannels = channels.filter(c => c.type === 'voice' || c.type === 'video');
@@ -306,7 +308,7 @@ export function ChannelSidebar() {
</svg> </svg>
<span className="text-[12px] font-bold uppercase tracking-wider">Text Channels</span> <span className="text-[12px] font-bold uppercase tracking-wider">Text Channels</span>
</div> </div>
{isAdminUser && ( {canManageChannels && (
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
@@ -358,7 +360,7 @@ export function ChannelSidebar() {
</svg> </svg>
<span className="text-[12px] font-bold uppercase tracking-wider">Voice Channels</span> <span className="text-[12px] font-bold uppercase tracking-wider">Voice Channels</span>
</div> </div>
{isAdminUser && ( {canManageChannels && (
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
@@ -7,6 +7,7 @@ import { Avatar } from '../ui/Avatar';
import { api } from '../../api/client'; import { api } from '../../api/client';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import type { MemberRole } from '@opencord/shared'; import type { MemberRole } from '@opencord/shared';
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
export function ServerSettingsModal() { export function ServerSettingsModal() {
const activeModal = useUIStore((s) => s.activeModal); const activeModal = useUIStore((s) => s.activeModal);
@@ -26,9 +27,13 @@ export function ServerSettingsModal() {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false);
const serverPermissions = useServerStore((s) => s.serverPermissions);
const isOpen = activeModal === 'serverSettings'; const isOpen = activeModal === 'serverSettings';
const server = servers.find(s => s.id === currentServerId); const server = servers.find(s => s.id === currentServerId);
const isOwnerUser = server?.ownerId === currentUser?.id; const isOwnerUser = server?.ownerId === currentUser?.id;
const myServerPerms = currentServerId ? serverPermissions.get(currentServerId) : undefined;
const canManageServer = hasPermissionBit(myServerPerms, PermissionBits.MANAGE_SERVER);
React.useEffect(() => { React.useEffect(() => {
if (server) { if (server) {
@@ -122,11 +127,11 @@ export function ServerSettingsModal() {
value={serverName} value={serverName}
onChange={(e) => setServerName(e.target.value)} onChange={(e) => setServerName(e.target.value)}
className="w-full px-3 py-2 bg-discord-bg-tertiary rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple" className="w-full px-3 py-2 bg-discord-bg-tertiary rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple"
disabled={!isOwnerUser} disabled={!canManageServer}
/> />
</div> </div>
{isOwnerUser && ( {canManageServer && (
<> <>
<button <button
onClick={handleSave} onClick={handleSave}
+16 -1
View File
@@ -12,6 +12,8 @@ interface ServerState {
dmChannels: DmChannel[]; dmChannels: DmChannel[];
channelToServerMap: Map<string, string>; channelToServerMap: Map<string, string>;
channelLastMessageIds: Map<string, string>; channelLastMessageIds: Map<string, string>;
serverPermissions: Map<string, string>; // serverId → myPermissions decimal string
channelPermissions: Map<string, string>; // channelId → myPermissions decimal string
setServers: (servers: Server[]) => void; setServers: (servers: Server[]) => void;
setCurrentServer: (serverId: string | null) => void; setCurrentServer: (serverId: string | null) => void;
setChannels: (channels: Channel[]) => void; setChannels: (channels: Channel[]) => void;
@@ -52,6 +54,8 @@ export const useServerStore = create<ServerState>((set, get) => ({
dmChannels: [], dmChannels: [],
channelToServerMap: new Map(), channelToServerMap: new Map(),
channelLastMessageIds: new Map(), channelLastMessageIds: new Map(),
serverPermissions: new Map(),
channelPermissions: new Map(),
setServers: (servers) => set({ servers }), setServers: (servers) => set({ servers }),
setCurrentServer: (serverId) => set({ currentServerId: serverId }), setCurrentServer: (serverId) => set({ currentServerId: serverId }),
@@ -225,15 +229,24 @@ export const useServerStore = create<ServerState>((set, get) => ({
createdAt: s.createdAt, createdAt: s.createdAt,
})); }));
// Build channel→server map and channel→lastMessageId map // Build channel→server map, channel→lastMessageId map, and permission maps
const channelToServerMap = new Map<string, string>(); const channelToServerMap = new Map<string, string>();
const channelLastMessageIds = new Map<string, string>(); const channelLastMessageIds = new Map<string, string>();
const serverPermissions = new Map<string, string>();
const channelPermissions = new Map<string, string>();
for (const srv of servers) { for (const srv of servers) {
if (srv.myPermissions) {
serverPermissions.set(srv.id, srv.myPermissions);
}
for (const ch of srv.channels) { for (const ch of srv.channels) {
channelToServerMap.set(ch.id, srv.id); channelToServerMap.set(ch.id, srv.id);
if (ch.lastMessageId) { if (ch.lastMessageId) {
channelLastMessageIds.set(ch.id, ch.lastMessageId); channelLastMessageIds.set(ch.id, ch.lastMessageId);
} }
if (ch.myPermissions) {
channelPermissions.set(ch.id, ch.myPermissions);
}
} }
} }
// Also map DM channels // Also map DM channels
@@ -250,6 +263,8 @@ export const useServerStore = create<ServerState>((set, get) => ({
dmChannels: dms, dmChannels: dms,
channelToServerMap, channelToServerMap,
channelLastMessageIds, channelLastMessageIds,
serverPermissions,
channelPermissions,
}); });
}, },
})); }));
+11
View File
@@ -0,0 +1,11 @@
// Frontend permission helpers — wraps shared permission constants.
// Always works with string representations (never raw bigint in state).
export {
PermissionBits,
ALL_PERMISSIONS,
DEFAULT_EVERYONE_PERMISSIONS,
hasPermissionBit,
permissionsToString,
stringToPermissions,
} from '@opencord/shared/src/permissions';