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),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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() {
|
||||
</svg>
|
||||
<span className="text-[12px] font-bold uppercase tracking-wider">Text Channels</span>
|
||||
</div>
|
||||
{isAdminUser && (
|
||||
{canManageChannels && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -358,7 +360,7 @@ export function ChannelSidebar() {
|
||||
</svg>
|
||||
<span className="text-[12px] font-bold uppercase tracking-wider">Voice Channels</span>
|
||||
</div>
|
||||
{isAdminUser && (
|
||||
{canManageChannels && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Avatar } from '../ui/Avatar';
|
||||
import { api } from '../../api/client';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { MemberRole } from '@opencord/shared';
|
||||
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
|
||||
|
||||
export function ServerSettingsModal() {
|
||||
const activeModal = useUIStore((s) => s.activeModal);
|
||||
@@ -26,9 +27,13 @@ export function ServerSettingsModal() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
|
||||
const serverPermissions = useServerStore((s) => s.serverPermissions);
|
||||
|
||||
const isOpen = activeModal === 'serverSettings';
|
||||
const server = servers.find(s => s.id === currentServerId);
|
||||
const isOwnerUser = server?.ownerId === currentUser?.id;
|
||||
const myServerPerms = currentServerId ? serverPermissions.get(currentServerId) : undefined;
|
||||
const canManageServer = hasPermissionBit(myServerPerms, PermissionBits.MANAGE_SERVER);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (server) {
|
||||
@@ -122,11 +127,11 @@ export function ServerSettingsModal() {
|
||||
value={serverName}
|
||||
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"
|
||||
disabled={!isOwnerUser}
|
||||
disabled={!canManageServer}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isOwnerUser && (
|
||||
{canManageServer && (
|
||||
<>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
|
||||
@@ -12,6 +12,8 @@ interface ServerState {
|
||||
dmChannels: DmChannel[];
|
||||
channelToServerMap: 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;
|
||||
setCurrentServer: (serverId: string | null) => void;
|
||||
setChannels: (channels: Channel[]) => void;
|
||||
@@ -52,6 +54,8 @@ export const useServerStore = create<ServerState>((set, get) => ({
|
||||
dmChannels: [],
|
||||
channelToServerMap: new Map(),
|
||||
channelLastMessageIds: new Map(),
|
||||
serverPermissions: new Map(),
|
||||
channelPermissions: new Map(),
|
||||
|
||||
setServers: (servers) => set({ servers }),
|
||||
setCurrentServer: (serverId) => set({ currentServerId: serverId }),
|
||||
@@ -225,15 +229,24 @@ export const useServerStore = create<ServerState>((set, get) => ({
|
||||
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 channelLastMessageIds = new Map<string, string>();
|
||||
const serverPermissions = new Map<string, string>();
|
||||
const channelPermissions = new Map<string, string>();
|
||||
|
||||
for (const srv of servers) {
|
||||
if (srv.myPermissions) {
|
||||
serverPermissions.set(srv.id, srv.myPermissions);
|
||||
}
|
||||
for (const ch of srv.channels) {
|
||||
channelToServerMap.set(ch.id, srv.id);
|
||||
if (ch.lastMessageId) {
|
||||
channelLastMessageIds.set(ch.id, ch.lastMessageId);
|
||||
}
|
||||
if (ch.myPermissions) {
|
||||
channelPermissions.set(ch.id, ch.myPermissions);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Also map DM channels
|
||||
@@ -250,6 +263,8 @@ export const useServerStore = create<ServerState>((set, get) => ({
|
||||
dmChannels: dms,
|
||||
channelToServerMap,
|
||||
channelLastMessageIds,
|
||||
serverPermissions,
|
||||
channelPermissions,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user