openvibers/openvibe.chat Commits
Staff gates ask the contracts staff map (D05, ADR-022): permissions.can(user, 'staff.<area>.<action>') over openvibe-contracts v0.35.0 (issued staff_caps claims win); per-IP and ban exemptions are staff.limits.exempt, chat-restriction bypass staff.moderation.bypass, purge/export/deleted-message views staff.moderation.purge, sound management staff.assets.manage, and the moderation/log helpers answer through the map. Same answers as before for every role; the one rank check left compares the target. test/staff-capabilities.test.js
Changes
@@@ -16,7 +16,7 @@"express": "^4.21.2","express-rate-limit": "^7.5.0","multer": "^2.0.2",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.33.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.35.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.6.0","ws": "^8.18.0"},@@@ -955,9 +955,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.33.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.33.0",-- "integrity": "sha512-7q8NUhcPxgb8lxrVdC3pFbiPdjzliJM1mMP/1G5a2PmOr2tJVAXwYa8+a8YgCJvYeMSeMHWijLkIEiZ9IhllOg==",++ "version": "0.35.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.35.0",++ "integrity": "sha512-5jkH9Gt9MVbYaxo/Fi1VSH0RwdPkbary7SOm2m9e5EvXYu8L/cJHMm6KrMKxa7MV6TJCATDeRpGLDOCaUeZmSw==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -27,7 +27,7 @@"express": "^4.21.2","express-rate-limit": "^7.5.0","multer": "^2.0.2",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.33.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.35.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.6.0","ws": "^8.18.0"},@@@ -168,7 +168,7 @@ function createApp({ chatServer, bridge, mirror, relay, events = null }) {req._ovBanUser = user;}const u = req._ovBanUser;-- return !!(u && !u.is_banned && u.role === 'admin');++ return !!(u && !u.is_banned && require('./auth/permissions').can(u, 'staff.limits.exempt'));}app.use(async (req, res, next) => {try {@@@ -213,7 +213,7 @@ function createApp({ chatServer, bridge, mirror, relay, events = null }) {if (ctx.isIpBanned(wsIp, null)) {// Admins pass network bans (shared home network).let exempt = false;-- try { const u = await authenticateWs(extractWsToken(req)); exempt = !!(u && !u.is_banned && u.role === 'admin'); } catch { exempt = false; }++ try { const u = await authenticateWs(extractWsToken(req)); exempt = !!(u && !u.is_banned && require('./auth/permissions').can(u, 'staff.limits.exempt')); } catch { exempt = false; }if (!exempt) { socket.destroy(); return; }}} catch { /* non-critical — allow through on a policy error */ }@@@ -162,7 +162,7 @@ async function optionalAuth(req, res, next) {*/function requireAdmin(req, res, next) {requireAuth(req, res, () => {-- if (req.user.role !== 'admin') {++ if (!require('./permissions').isAdmin(req.user)) {return res.status(403).json({ error: 'Admin access required' });}next();@@@ -11,6 +11,7 @@'use strict';const ctx = require('../live-context');++const { staff } = require('openvibe-contracts');// ── Role hierarchy (higher = more power) ─────────────────────const ROLE_RANK = {@@@ -98,7 +99,7 @@ function getChannelIdForStream(streamId) {*/function canModerateChannel(user, channelId) {if (!user) return false;-- if (isGlobalModOrAbove(user)) return true;++ if (can(user, 'staff.moderation.chat')) return true;if (isChannelOwner(user, channelId)) return true;return isChannelMod(user, channelId);}@@@ -110,7 +111,7 @@ function canModerateChannel(user, channelId) {*/function canModerateStream(user, streamId) {if (!user) return false;-- if (isGlobalModOrAbove(user)) return true;++ if (can(user, 'staff.moderation.chat')) return true;if (isStreamOwner(user, streamId)) return true;const channelId = getChannelIdForStream(streamId);if (channelId && isChannelMod(user, channelId)) return true;@@@ -122,7 +123,7 @@ function canModerateStream(user, streamId) {*/function canViewChatLogs(user, scope = 'own') {if (!user) return false;-- if (isGlobalModOrAbove(user)) return true;++ if (can(user, 'staff.moderation.logs')) return true;return scope === 'own';}@@@ -130,10 +131,24 @@ function canViewChatLogs(user, scope = 'own') {* Can this user view another user's chat logs?*/function canViewOtherUserLogs(user) {-- return isGlobalModOrAbove(user);++ return can(user, 'staff.moderation.logs');++}++++// ── Staff capabilities (openvibe-contracts manifests/policy/staff-roles.json, ADR-022) ──────++// Every staff gate asks can(user, 'staff.<area>.<action>'); issued staff_caps claims win when present.++function staffClaims(user) {++ if (!user) return null;++ const c = { role: user.role, is_owner: isOwner(user) };++ if (Array.isArray(user.staff_caps)) c.staff_caps = user.staff_caps;++ return c;++}++function can(user, capability) {++ return !!user && staff.can(staffClaims(user), capability);}module.exports = {++ can,++ staffClaims,ROLE_RANK,roleRank,isAdmin,@@@ -121,7 +121,7 @@ class ChatServer {return anonAge == null ? false : anonAge < DAY;}-- _isBanExemptAdmin(client) { return !!(client && client.user && !client.user.is_banned && client.user.role === 'admin'); }++ _isBanExemptAdmin(client) { return !!(client && client.user && !client.user.is_banned && permissions.can(client.user, 'staff.limits.exempt')); }getClientIp(req) {// CF-Connecting-IP / X-Forwarded-For only when nginx's peer is Cloudflare (net/client-ip.js).@@@ -293,7 +293,7 @@ class ChatServer {/** The rest of Live's handleConnection, once the socket's identity is known. */_registerConnection(ws, req, { ip, streamId, user, early }) {const perIp = this._ipSockets.get(ip) || 0;-- if (ip && ip !== 'unknown' && perIp >= MAX_CHAT_SOCKETS_PER_IP && !(user && user.role === 'admin')) {++ if (ip && ip !== 'unknown' && perIp >= MAX_CHAT_SOCKETS_PER_IP && !permissions.can(user, 'staff.limits.exempt')) {ws.close(4029, 'Too many connections');return;}@@@ -661,7 +661,7 @@ class ChatServer {if (channel) {const settings = ctx.getChannelModerationSettings(channel.id);if (settings?.ip_approval_mode) {-- const isStaffBypass = client.user && permissions.isGlobalModOrAbove(client.user);++ const isStaffBypass = permissions.can(client.user, 'staff.moderation.bypass');const isOwner = client.user && stream && stream.user_id === client.user.id;if (!isStaffBypass && !isOwner) {if (!ctx.isIpApproved(channel.id, client.ip)) {@@@ -962,7 +962,7 @@ class ChatServer {_chatRulesBlock(ws, client, text, modStreamId) {if (!modStreamId) return false;const chatSettings = this._getChannelChatSettings(modStreamId);-- const isStaff = client.user && permissions.isGlobalModOrAbove(client.user);++ const isStaff = permissions.can(client.user, 'staff.moderation.bypass');const canModerateThisStream = permissions.canModerateStream(client.user, modStreamId);// Max message length@@@ -1720,7 +1720,7 @@ class ChatServer {const targetUser = await ctx.ensureUserByUsername(target);if (targetUser) {// Prevent non-admins from banning admins-- if (permissions.isGlobalModOrAbove(targetUser) && targetUser.role === 'admin' && client.user.role !== 'admin') {++ if (permissions.isGlobalModOrAbove(targetUser) && targetUser.role === 'admin' && !permissions.isAdmin(client.user)) {this.sendTo(ws, { type: 'system', message: 'You cannot ban an admin.' });return;}@@@ -630,7 +630,7 @@ router.post('/admin/purge/preview', requireAuth, async (req, res) => {// Must be admin or stream ownerlet effectiveStreamId = streamId || null;-- if (!permissions.isAdmin(req.user)) {++ if (!permissions.can(req.user, 'staff.moderation.purge')) {if (streamId) {const stream = await ctx.ensureStream(streamId);if (!stream || stream.user_id !== req.user.id) {@@@ -661,7 +661,7 @@ router.delete('/admin/purge', requireAuth, async (req, res) => {if (!from || !to) return res.status(400).json({ error: 'from and to are required' });let effectiveStreamId = streamId || null;-- if (!permissions.isAdmin(req.user)) {++ if (!permissions.can(req.user, 'staff.moderation.purge')) {if (streamId) {const stream = await ctx.ensureStream(streamId);if (!stream || stream.user_id !== req.user.id) {@@@ -705,7 +705,7 @@ router.get('/admin/logs', requireAuth, async (req, res) => {// Must be admin or stream ownerlet effectiveStreamId = streamId ? parseInt(streamId) : undefined;-- if (!permissions.isAdmin(req.user)) {++ if (!permissions.can(req.user, 'staff.moderation.purge')) {if (streamId) {const stream = await ctx.ensureStream(parseInt(streamId));if (!stream || stream.user_id !== req.user.id) {@@@ -726,7 +726,7 @@ router.get('/admin/logs', requireAuth, async (req, res) => {username, search, from, to, messageType,page: parseInt(page) || 1,limit: Math.min(parseInt(limit) || 50, 200),-- includeDeleted: includeDeleted === 'true' && permissions.isAdmin(req.user),++ includeDeleted: includeDeleted === 'true' && permissions.can(req.user, 'staff.moderation.purge'),});publicRows(result.rows);res.json(result);@@@ -741,7 +741,7 @@ router.get('/admin/logs/export', requireAuth, async (req, res) => {try {const { streamId, username, search, from, to, messageType, format } = req.query;-- if (!permissions.isAdmin(req.user)) {++ if (!permissions.can(req.user, 'staff.moderation.purge')) {if (streamId) {const stream = await ctx.ensureStream(parseInt(streamId));if (!stream || stream.user_id !== req.user.id) {@@@ -308,7 +308,7 @@ router.delete('/:id', requireAuth, async (req, res) => {try {const sound = db.getChannelSoundById(parseInt(req.params.id));if (!sound) return res.status(404).json({ error: 'Sound not found' });-- let allowed = sound.created_by === req.user.id || req.user.role === 'admin';++ let allowed = sound.created_by === req.user.id || permissions.can(req.user, 'staff.assets.manage');if (!allowed) {const channel = await channelWithPolicy(sound.channel_owner_id);if (channel && permissions.canModerateChannel(req.user, channel.id)) allowed = true;@@@ -348,7 +348,7 @@ router.patch('/command', requireAuth, async (req, res) => {const isMod = permissions.canModerateChannel(req.user, channel.id);const isOwnChannel = channelOwnerId === req.user.id;const isCreator = group.every((s) => s.created_by === req.user.id);-- if (!isMod && !isOwnChannel && !isCreator && req.user.role !== 'admin') {++ if (!isMod && !isOwnChannel && !isCreator && !permissions.can(req.user, 'staff.assets.manage')) {return res.status(403).json({ error: `Only the creator of !${command} (or a mod) can edit it.` });}nnew file mode 100644@@@ -0,0 +1,34 @@++'use strict';++/** Chat's staff gates ask the openvibe-contracts staff map (ADR-022); no server file compares an actor's role by hand. */++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const { staff } = require('openvibe-contracts');++const p = require('../server/auth/permissions');++++const people = { user: { id: 1, role: 'user' }, global_mod: { id: 2, role: 'global_mod' }, admin: { id: 3, role: 'admin' }, owner: { id: 4, role: 'admin', is_owner: 1 } };++for (const [name, u] of Object.entries(people)) {++ const map = new Set(staff.capabilitiesOf({ role: u.role, is_owner: !!u.is_owner }));++ for (const cap of staff.map.capabilities.map((c) => c.id)) assert.strictEqual(p.can(u, cap), map.has(cap), `${name} ${cap}`);++ assert.strictEqual(p.canViewOtherUserLogs(u), map.has('staff.moderation.logs'), `${name} logs`);++}++assert.strictEqual(p.can(null, 'staff.moderation.chat'), false);++assert.strictEqual(p.can({ id: 5, role: 'user', staff_caps: ['staff.moderation.bypass'] }, 'staff.moderation.bypass'), true, 'issued claims win');++assert.throws(() => p.can(people.admin, 'staff.nope'), /unknown staff capability/);++++const TARGET_RANK = [['chat/chat-server.js', "targetUser.role === 'admin'"]];++const offenders = [];++(function walk(dir) {++ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {++ const f = path.join(dir, e.name);++ if (e.isDirectory()) { walk(f); continue; }++ if (!e.name.endsWith('.js')) continue;++ const rel = path.relative(path.join(__dirname, '..', 'server'), f).split(path.sep).join('/');++ if (rel === 'auth/permissions.js') continue;++ fs.readFileSync(f, 'utf8').split('\n').forEach((line, i) => {++ if (/role\s*[!=]==?\s*'(admin|global_mod)'/.test(line) && !TARGET_RANK.some(([file, snip]) => file === rel && line.includes(snip))) offenders.push(`server/${rel}:${i + 1}`);++ });++ }++})(path.join(__dirname, '..', 'server'));++assert.deepStrictEqual(offenders, [], 'raw role checks; use permissions.can(user, \'staff.…\')');++console.log('staff capabilities: all checks passed');
@@ -16,7 +16,7 @@
@@ -16,7 +16,7 @@
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"multer": "^2.0.2",
"multer": "^2.0.2",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.33.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.35.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.6.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.6.0",
"ws": "^8.18.0"
"ws": "^8.18.0"
},
},
@@ -955,9 +955,9 @@
@@ -955,9 +955,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.33.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.33.0",
- "integrity": "sha512-7q8NUhcPxgb8lxrVdC3pFbiPdjzliJM1mMP/1G5a2PmOr2tJVAXwYa8+a8YgCJvYeMSeMHWijLkIEiZ9IhllOg==",
+ "version": "0.35.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.35.0",
+ "integrity": "sha512-5jkH9Gt9MVbYaxo/Fi1VSH0RwdPkbary7SOm2m9e5EvXYu8L/cJHMm6KrMKxa7MV6TJCATDeRpGLDOCaUeZmSw==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -27,7 +27,7 @@
@@ -27,7 +27,7 @@
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"multer": "^2.0.2",
"multer": "^2.0.2",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.33.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.35.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.6.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.6.0",
"ws": "^8.18.0"
"ws": "^8.18.0"
},
},
@@ -168,7 +168,7 @@ function createApp({ chatServer, bridge, mirror, relay, events = null }) {
@@ -168,7 +168,7 @@ function createApp({ chatServer, bridge, mirror, relay, events = null }) {
req._ovBanUser = user;
req._ovBanUser = user;
}
}
const u = req._ovBanUser;
const u = req._ovBanUser;
- return !!(u && !u.is_banned && u.role === 'admin');
+ return !!(u && !u.is_banned && require('./auth/permissions').can(u, 'staff.limits.exempt'));
}
}
app.use(async (req, res, next) => {
app.use(async (req, res, next) => {
try {
try {
@@ -213,7 +213,7 @@ function createApp({ chatServer, bridge, mirror, relay, events = null }) {
@@ -213,7 +213,7 @@ function createApp({ chatServer, bridge, mirror, relay, events = null }) {
if (ctx.isIpBanned(wsIp, null)) {
if (ctx.isIpBanned(wsIp, null)) {
// Admins pass network bans (shared home network).
// Admins pass network bans (shared home network).
let exempt = false;
let exempt = false;
- try { const u = await authenticateWs(extractWsToken(req)); exempt = !!(u && !u.is_banned && u.role === 'admin'); } catch { exempt = false; }
+ try { const u = await authenticateWs(extractWsToken(req)); exempt = !!(u && !u.is_banned && require('./auth/permissions').can(u, 'staff.limits.exempt')); } catch { exempt = false; }
if (!exempt) { socket.destroy(); return; }
if (!exempt) { socket.destroy(); return; }
}
}
} catch { /* non-critical — allow through on a policy error */ }
} catch { /* non-critical — allow through on a policy error */ }
@@ -162,7 +162,7 @@ async function optionalAuth(req, res, next) {
@@ -162,7 +162,7 @@ async function optionalAuth(req, res, next) {
*/
*/
function requireAdmin(req, res, next) {
function requireAdmin(req, res, next) {
requireAuth(req, res, () => {
requireAuth(req, res, () => {
- if (req.user.role !== 'admin') {
+ if (!require('./permissions').isAdmin(req.user)) {
return res.status(403).json({ error: 'Admin access required' });
return res.status(403).json({ error: 'Admin access required' });
}
}
next();
next();
@@ -11,6 +11,7 @@
@@ -11,6 +11,7 @@
'use strict';
'use strict';
const ctx = require('../live-context');
const ctx = require('../live-context');
+const { staff } = require('openvibe-contracts');
// ── Role hierarchy (higher = more power) ─────────────────────
// ── Role hierarchy (higher = more power) ─────────────────────
const ROLE_RANK = {
const ROLE_RANK = {
@@ -98,7 +99,7 @@ function getChannelIdForStream(streamId) {
@@ -98,7 +99,7 @@ function getChannelIdForStream(streamId) {
*/
*/
function canModerateChannel(user, channelId) {
function canModerateChannel(user, channelId) {
if (!user) return false;
if (!user) return false;
- if (isGlobalModOrAbove(user)) return true;
+ if (can(user, 'staff.moderation.chat')) return true;
if (isChannelOwner(user, channelId)) return true;
if (isChannelOwner(user, channelId)) return true;
return isChannelMod(user, channelId);
return isChannelMod(user, channelId);
}
}
@@ -110,7 +111,7 @@ function canModerateChannel(user, channelId) {
@@ -110,7 +111,7 @@ function canModerateChannel(user, channelId) {
*/
*/
function canModerateStream(user, streamId) {
function canModerateStream(user, streamId) {
if (!user) return false;
if (!user) return false;
- if (isGlobalModOrAbove(user)) return true;
+ if (can(user, 'staff.moderation.chat')) return true;
if (isStreamOwner(user, streamId)) return true;
if (isStreamOwner(user, streamId)) return true;
const channelId = getChannelIdForStream(streamId);
const channelId = getChannelIdForStream(streamId);
if (channelId && isChannelMod(user, channelId)) return true;
if (channelId && isChannelMod(user, channelId)) return true;
@@ -122,7 +123,7 @@ function canModerateStream(user, streamId) {
@@ -122,7 +123,7 @@ function canModerateStream(user, streamId) {
*/
*/
function canViewChatLogs(user, scope = 'own') {
function canViewChatLogs(user, scope = 'own') {
if (!user) return false;
if (!user) return false;
- if (isGlobalModOrAbove(user)) return true;
+ if (can(user, 'staff.moderation.logs')) return true;
return scope === 'own';
return scope === 'own';
}
}
@@ -130,10 +131,24 @@ function canViewChatLogs(user, scope = 'own') {
@@ -130,10 +131,24 @@ function canViewChatLogs(user, scope = 'own') {
* Can this user view another user's chat logs?
* Can this user view another user's chat logs?
*/
*/
function canViewOtherUserLogs(user) {
function canViewOtherUserLogs(user) {
- return isGlobalModOrAbove(user);
+ return can(user, 'staff.moderation.logs');
+}
+
+// ── Staff capabilities (openvibe-contracts manifests/policy/staff-roles.json, ADR-022) ──────
+// Every staff gate asks can(user, 'staff.<area>.<action>'); issued staff_caps claims win when present.
+function staffClaims(user) {
+ if (!user) return null;
+ const c = { role: user.role, is_owner: isOwner(user) };
+ if (Array.isArray(user.staff_caps)) c.staff_caps = user.staff_caps;
+ return c;
+}
+function can(user, capability) {
+ return !!user && staff.can(staffClaims(user), capability);
}
}
module.exports = {
module.exports = {
+ can,
+ staffClaims,
ROLE_RANK,
ROLE_RANK,
roleRank,
roleRank,
isAdmin,
isAdmin,
@@ -121,7 +121,7 @@ class ChatServer {
@@ -121,7 +121,7 @@ class ChatServer {
return anonAge == null ? false : anonAge < DAY;
return anonAge == null ? false : anonAge < DAY;
}
}
- _isBanExemptAdmin(client) { return !!(client && client.user && !client.user.is_banned && client.user.role === 'admin'); }
+ _isBanExemptAdmin(client) { return !!(client && client.user && !client.user.is_banned && permissions.can(client.user, 'staff.limits.exempt')); }
getClientIp(req) {
getClientIp(req) {
// CF-Connecting-IP / X-Forwarded-For only when nginx's peer is Cloudflare (net/client-ip.js).
// CF-Connecting-IP / X-Forwarded-For only when nginx's peer is Cloudflare (net/client-ip.js).
@@ -293,7 +293,7 @@ class ChatServer {
@@ -293,7 +293,7 @@ class ChatServer {
/** The rest of Live's handleConnection, once the socket's identity is known. */
/** The rest of Live's handleConnection, once the socket's identity is known. */
_registerConnection(ws, req, { ip, streamId, user, early }) {
_registerConnection(ws, req, { ip, streamId, user, early }) {
const perIp = this._ipSockets.get(ip) || 0;
const perIp = this._ipSockets.get(ip) || 0;
- if (ip && ip !== 'unknown' && perIp >= MAX_CHAT_SOCKETS_PER_IP && !(user && user.role === 'admin')) {
+ if (ip && ip !== 'unknown' && perIp >= MAX_CHAT_SOCKETS_PER_IP && !permissions.can(user, 'staff.limits.exempt')) {
ws.close(4029, 'Too many connections');
ws.close(4029, 'Too many connections');
return;
return;
}
}
@@ -661,7 +661,7 @@ class ChatServer {
@@ -661,7 +661,7 @@ class ChatServer {
if (channel) {
if (channel) {
const settings = ctx.getChannelModerationSettings(channel.id);
const settings = ctx.getChannelModerationSettings(channel.id);
if (settings?.ip_approval_mode) {
if (settings?.ip_approval_mode) {
- const isStaffBypass = client.user && permissions.isGlobalModOrAbove(client.user);
+ const isStaffBypass = permissions.can(client.user, 'staff.moderation.bypass');
const isOwner = client.user && stream && stream.user_id === client.user.id;
const isOwner = client.user && stream && stream.user_id === client.user.id;
if (!isStaffBypass && !isOwner) {
if (!isStaffBypass && !isOwner) {
if (!ctx.isIpApproved(channel.id, client.ip)) {
if (!ctx.isIpApproved(channel.id, client.ip)) {
@@ -962,7 +962,7 @@ class ChatServer {
@@ -962,7 +962,7 @@ class ChatServer {
_chatRulesBlock(ws, client, text, modStreamId) {
_chatRulesBlock(ws, client, text, modStreamId) {
if (!modStreamId) return false;
if (!modStreamId) return false;
const chatSettings = this._getChannelChatSettings(modStreamId);
const chatSettings = this._getChannelChatSettings(modStreamId);
- const isStaff = client.user && permissions.isGlobalModOrAbove(client.user);
+ const isStaff = permissions.can(client.user, 'staff.moderation.bypass');
const canModerateThisStream = permissions.canModerateStream(client.user, modStreamId);
const canModerateThisStream = permissions.canModerateStream(client.user, modStreamId);
// Max message length
// Max message length
@@ -1720,7 +1720,7 @@ class ChatServer {
@@ -1720,7 +1720,7 @@ class ChatServer {
const targetUser = await ctx.ensureUserByUsername(target);
const targetUser = await ctx.ensureUserByUsername(target);
if (targetUser) {
if (targetUser) {
// Prevent non-admins from banning admins
// Prevent non-admins from banning admins
- if (permissions.isGlobalModOrAbove(targetUser) && targetUser.role === 'admin' && client.user.role !== 'admin') {
+ if (permissions.isGlobalModOrAbove(targetUser) && targetUser.role === 'admin' && !permissions.isAdmin(client.user)) {
this.sendTo(ws, { type: 'system', message: 'You cannot ban an admin.' });
this.sendTo(ws, { type: 'system', message: 'You cannot ban an admin.' });
return;
return;
}
}
@@ -630,7 +630,7 @@ router.post('/admin/purge/preview', requireAuth, async (req, res) => {
@@ -630,7 +630,7 @@ router.post('/admin/purge/preview', requireAuth, async (req, res) => {
// Must be admin or stream owner
// Must be admin or stream owner
let effectiveStreamId = streamId || null;
let effectiveStreamId = streamId || null;
- if (!permissions.isAdmin(req.user)) {
+ if (!permissions.can(req.user, 'staff.moderation.purge')) {
if (streamId) {
if (streamId) {
const stream = await ctx.ensureStream(streamId);
const stream = await ctx.ensureStream(streamId);
if (!stream || stream.user_id !== req.user.id) {
if (!stream || stream.user_id !== req.user.id) {
@@ -661,7 +661,7 @@ router.delete('/admin/purge', requireAuth, async (req, res) => {
@@ -661,7 +661,7 @@ router.delete('/admin/purge', requireAuth, async (req, res) => {
if (!from || !to) return res.status(400).json({ error: 'from and to are required' });
if (!from || !to) return res.status(400).json({ error: 'from and to are required' });
let effectiveStreamId = streamId || null;
let effectiveStreamId = streamId || null;
- if (!permissions.isAdmin(req.user)) {
+ if (!permissions.can(req.user, 'staff.moderation.purge')) {
if (streamId) {
if (streamId) {
const stream = await ctx.ensureStream(streamId);
const stream = await ctx.ensureStream(streamId);
if (!stream || stream.user_id !== req.user.id) {
if (!stream || stream.user_id !== req.user.id) {
@@ -705,7 +705,7 @@ router.get('/admin/logs', requireAuth, async (req, res) => {
@@ -705,7 +705,7 @@ router.get('/admin/logs', requireAuth, async (req, res) => {
// Must be admin or stream owner
// Must be admin or stream owner
let effectiveStreamId = streamId ? parseInt(streamId) : undefined;
let effectiveStreamId = streamId ? parseInt(streamId) : undefined;
- if (!permissions.isAdmin(req.user)) {
+ if (!permissions.can(req.user, 'staff.moderation.purge')) {
if (streamId) {
if (streamId) {
const stream = await ctx.ensureStream(parseInt(streamId));
const stream = await ctx.ensureStream(parseInt(streamId));
if (!stream || stream.user_id !== req.user.id) {
if (!stream || stream.user_id !== req.user.id) {
@@ -726,7 +726,7 @@ router.get('/admin/logs', requireAuth, async (req, res) => {
@@ -726,7 +726,7 @@ router.get('/admin/logs', requireAuth, async (req, res) => {
username, search, from, to, messageType,
username, search, from, to, messageType,
page: parseInt(page) || 1,
page: parseInt(page) || 1,
limit: Math.min(parseInt(limit) || 50, 200),
limit: Math.min(parseInt(limit) || 50, 200),
- includeDeleted: includeDeleted === 'true' && permissions.isAdmin(req.user),
+ includeDeleted: includeDeleted === 'true' && permissions.can(req.user, 'staff.moderation.purge'),
});
});
publicRows(result.rows);
publicRows(result.rows);
res.json(result);
res.json(result);
@@ -741,7 +741,7 @@ router.get('/admin/logs/export', requireAuth, async (req, res) => {
@@ -741,7 +741,7 @@ router.get('/admin/logs/export', requireAuth, async (req, res) => {
try {
try {
const { streamId, username, search, from, to, messageType, format } = req.query;
const { streamId, username, search, from, to, messageType, format } = req.query;
- if (!permissions.isAdmin(req.user)) {
+ if (!permissions.can(req.user, 'staff.moderation.purge')) {
if (streamId) {
if (streamId) {
const stream = await ctx.ensureStream(parseInt(streamId));
const stream = await ctx.ensureStream(parseInt(streamId));
if (!stream || stream.user_id !== req.user.id) {
if (!stream || stream.user_id !== req.user.id) {
@@ -308,7 +308,7 @@ router.delete('/:id', requireAuth, async (req, res) => {
@@ -308,7 +308,7 @@ router.delete('/:id', requireAuth, async (req, res) => {
try {
try {
const sound = db.getChannelSoundById(parseInt(req.params.id));
const sound = db.getChannelSoundById(parseInt(req.params.id));
if (!sound) return res.status(404).json({ error: 'Sound not found' });
if (!sound) return res.status(404).json({ error: 'Sound not found' });
- let allowed = sound.created_by === req.user.id || req.user.role === 'admin';
+ let allowed = sound.created_by === req.user.id || permissions.can(req.user, 'staff.assets.manage');
if (!allowed) {
if (!allowed) {
const channel = await channelWithPolicy(sound.channel_owner_id);
const channel = await channelWithPolicy(sound.channel_owner_id);
if (channel && permissions.canModerateChannel(req.user, channel.id)) allowed = true;
if (channel && permissions.canModerateChannel(req.user, channel.id)) allowed = true;
@@ -348,7 +348,7 @@ router.patch('/command', requireAuth, async (req, res) => {
@@ -348,7 +348,7 @@ router.patch('/command', requireAuth, async (req, res) => {
const isMod = permissions.canModerateChannel(req.user, channel.id);
const isMod = permissions.canModerateChannel(req.user, channel.id);
const isOwnChannel = channelOwnerId === req.user.id;
const isOwnChannel = channelOwnerId === req.user.id;
const isCreator = group.every((s) => s.created_by === req.user.id);
const isCreator = group.every((s) => s.created_by === req.user.id);
- if (!isMod && !isOwnChannel && !isCreator && req.user.role !== 'admin') {
+ if (!isMod && !isOwnChannel && !isCreator && !permissions.can(req.user, 'staff.assets.manage')) {
return res.status(403).json({ error: `Only the creator of !${command} (or a mod) can edit it.` });
return res.status(403).json({ error: `Only the creator of !${command} (or a mod) can edit it.` });
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,34 @@
@@ -0,0 +1,34 @@
+'use strict';
+/** Chat's staff gates ask the openvibe-contracts staff map (ADR-022); no server file compares an actor's role by hand. */
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const { staff } = require('openvibe-contracts');
+const p = require('../server/auth/permissions');
+
+const people = { user: { id: 1, role: 'user' }, global_mod: { id: 2, role: 'global_mod' }, admin: { id: 3, role: 'admin' }, owner: { id: 4, role: 'admin', is_owner: 1 } };
+for (const [name, u] of Object.entries(people)) {
+ const map = new Set(staff.capabilitiesOf({ role: u.role, is_owner: !!u.is_owner }));
+ for (const cap of staff.map.capabilities.map((c) => c.id)) assert.strictEqual(p.can(u, cap), map.has(cap), `${name} ${cap}`);
+ assert.strictEqual(p.canViewOtherUserLogs(u), map.has('staff.moderation.logs'), `${name} logs`);
+}
+assert.strictEqual(p.can(null, 'staff.moderation.chat'), false);
+assert.strictEqual(p.can({ id: 5, role: 'user', staff_caps: ['staff.moderation.bypass'] }, 'staff.moderation.bypass'), true, 'issued claims win');
+assert.throws(() => p.can(people.admin, 'staff.nope'), /unknown staff capability/);
+
+const TARGET_RANK = [['chat/chat-server.js', "targetUser.role === 'admin'"]];
+const offenders = [];
+(function walk(dir) {
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
+ const f = path.join(dir, e.name);
+ if (e.isDirectory()) { walk(f); continue; }
+ if (!e.name.endsWith('.js')) continue;
+ const rel = path.relative(path.join(__dirname, '..', 'server'), f).split(path.sep).join('/');
+ if (rel === 'auth/permissions.js') continue;
+ fs.readFileSync(f, 'utf8').split('\n').forEach((line, i) => {
+ if (/role\s*[!=]==?\s*'(admin|global_mod)'/.test(line) && !TARGET_RANK.some(([file, snip]) => file === rel && line.includes(snip))) offenders.push(`server/${rel}:${i + 1}`);
+ });
+ }
+})(path.join(__dirname, '..', 'server'));
+assert.deepStrictEqual(offenders, [], 'raw role checks; use permissions.can(user, \'staff.…\')');
+console.log('staff capabilities: all checks passed');