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)
|
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,
|
||||||
|
|||||||
@@ -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...');
|
||||||
@@ -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.');
|
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] }),
|
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(),
|
||||||
|
|||||||
@@ -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`);
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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[];
|
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 {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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