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

OP OpenVibers committed 24 Sept 2026 · 990fbd1ee5 · 9 files, +73 −24


Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex 3356d0f..6b98962 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex ceff79e..1b0036b 100644
---- a/package.json
++++ b/package.json
@@@ -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"
},
ddiff --git a/server/app.js b/server/app.js
iindex ed996a4..81cff26 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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 */ }
ddiff --git a/server/auth/auth.js b/server/auth/auth.js
iindex ad20b90..22dfc27 100644
---- a/server/auth/auth.js
++++ b/server/auth/auth.js
@@@ -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();
ddiff --git a/server/auth/permissions.js b/server/auth/permissions.js
iindex d0189e2..6162e2e 100644
---- a/server/auth/permissions.js
++++ b/server/auth/permissions.js
@@@ -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,
ddiff --git a/server/chat/chat-server.js b/server/chat/chat-server.js
iindex 824f007..86d7bbe 100644
---- a/server/chat/chat-server.js
++++ b/server/chat/chat-server.js
@@@ -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;
}
ddiff --git a/server/chat/routes.js b/server/chat/routes.js
iindex c2daf54..203f516 100644
---- a/server/chat/routes.js
++++ b/server/chat/routes.js
@@@ -630,7 +630,7 @@ router.post('/admin/purge/preview', requireAuth, async (req, res) => {
// Must be admin or stream owner
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) {
@@@ -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 owner
let 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) {
ddiff --git a/server/chat/sounds-routes.js b/server/chat/sounds-routes.js
iindex 1a88170..2a31b3b 100644
---- a/server/chat/sounds-routes.js
++++ b/server/chat/sounds-routes.js
@@@ -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.` });
}
ddiff --git a/test/staff-capabilities.test.js b/test/staff-capabilities.test.js
nnew file mode 100644
iindex 0000000..0bf3c3c
---- /dev/null
++++ b/test/staff-capabilities.test.js
@@@ -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');