openvibers/openvibe.live Commits

Staff gates ask the contracts staff map (D05, ADR-022): permissions.can(user, 'staff.<area>.<action>') and requireCap() over openvibe-contracts v0.35.0 (staff-roles.json; issued staff_caps claims win when Network sends them). Every actor role comparison in server/ now names its capability (hardware, streams, assets, site configure, moderation calls/pastes, limits exempt, content view_private), and the role helpers (admin panel, users, secrets, money, bans, logs, force-end, …) answer through the map; /api/auth/me capabilities carry staff_caps. What remains compares a target's rank. Two answers now follow the map: global moderators may see private VODs and clips on stream pages (staff.content.view_private), and only admins skip the per-IP call-socket cap (staff.limits.exempt). test/staff-capabilities.test.js pins helper-map parity for every role and fails on any new raw role check

OP OpenVibers committed 24 Sept 2026 · ff7c5418e6 · 23 files, +217 −81


Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex 19e4d4f1..190f5adf 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -25,7 +25,7 @@
"multer": "^1.4.5-lts.1",
"node-media-server": "^2.7.1",
"onnxruntime-node": "^1.20.0",
-- "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.5.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.6.0",
"sanitize-html": "^2.17.7",
@@@ -2814,9 +2814,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 2a2f5c85..97d95a2c 100644
---- a/package.json
++++ b/package.json
@@@ -43,7 +43,7 @@
"multer": "^1.4.5-lts.1",
"node-media-server": "^2.7.1",
"onnxruntime-node": "^1.20.0",
-- "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.5.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.6.0",
"sanitize-html": "^2.17.7",
ddiff --git a/server/auth/auth.js b/server/auth/auth.js
iindex 82ee26fc..bc68d3d7 100644
---- a/server/auth/auth.js
++++ b/server/auth/auth.js
@@@ -300,7 +300,7 @@ 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 42f9dd22..57fd45db 100644
---- a/server/auth/permissions.js
++++ b/server/auth/permissions.js
@@@ -14,6 +14,7 @@
*/
const db = require('../db/database');
++const { staff } = require('openvibe-contracts');
// ── Role hierarchy (higher = more power) ─────────────────────
const ROLE_RANK = {
@@@ -154,62 +155,62 @@ function getChannelIdForStream(streamId) {
* Global mods see a subset of tabs (chat logs, bans).
*/
function canAccessAdminPanel(user) {
-- return isGlobalModOrAbove(user);
++ return can(user, 'staff.console.access');
}
/**
* Can this user manage users (role changes, bans, etc.)? (admin only)
*/
function canManageUsers(user) {
-- return isAdmin(user);
++ return can(user, 'staff.users.manage');
}
/**
* Can this user manage (promote/demote) global mods? (admin only)
*/
function canManageGlobalMods(user) {
-- return isAdmin(user);
++ return can(user, 'staff.roles.assign');
}
/**
* Can this user manage site settings? (admin only)
*/
function canManageSiteSettings(user) {
-- return isAdmin(user);
++ return can(user, 'staff.site.configure');
}
/** Can this user view/change API keys + other secret settings? (owner only) */
function canManageSecrets(user) {
-- return isOwner(user);
++ return can(user, 'staff.secrets.manage');
}
/** Can this user manage money — payments config, cashouts/payouts, funds? (owner only) */
function canManageMoney(user) {
-- return isOwner(user);
++ return can(user, 'staff.money.freeze');
}
/** Can this user grant/revoke the ADMIN role? (owner only) */
function canGrantAdmin(user) {
-- return isOwner(user);
++ return can(user, 'staff.roles.grant_admin');
}
/**
* Can this user review cashouts? Cashouts move real money out — owner only.
*/
function canReviewCashouts(user) {
-- return isOwner(user);
++ return can(user, 'staff.money.cashouts');
}
/**
* Can this user review VPN queue? (admin only)
*/
function canReviewVpn(user) {
-- return isAdmin(user);
++ return can(user, 'staff.moderation.vpn');
}
/**
* Can this user manage site-wide bans? (admin + global_mod)
*/
function canManageSiteBans(user) {
-- return isGlobalModOrAbove(user);
++ return can(user, 'staff.moderation.bans');
}
/**
@@@ -219,7 +220,7 @@ function canManageSiteBans(user) {
*/
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);
}
@@@ -231,7 +232,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;
@@@ -255,7 +256,7 @@ function canModerateCall(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';
}
@@@ -263,7 +264,7 @@ 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');
}
/**
@@@ -273,7 +274,7 @@ function canViewOtherUserLogs(user) {
*/
function canAssignChannelMods(user, channelId) {
if (!user) return false;
-- if (isAdmin(user)) return true;
++ if (can(user, 'staff.roles.assign')) return true;
return isChannelOwner(user, channelId);
}
@@@ -288,7 +289,7 @@ function canManageOwnChannel(user) {
* Can this user force-end any stream? (admin only)
*/
function canForceEndStreams(user) {
-- return isAdmin(user);
++ return can(user, 'staff.streams.end');
}
// ── Capability map (returned to frontend via /api/auth/me) ───
@@@ -319,6 +320,7 @@ function getCapabilities(user) {
view_ip_info: false,
owned_channel_id: null,
moderated_channel_ids: [],
++ staff_caps: [],
};
}
@@@ -344,7 +346,7 @@ function getCapabilities(user) {
can_access_staff_console: isStaffUser,
can_manage_channels: !!ownedChannel || moderatedChannels.length > 0 || isStaffUser,
can_moderate_site_chat: isStaffUser,
-- view_ip_info: isStaffUser,
++ view_ip_info: can(user, 'staff.moderation.ip'),
// Owner-only powers (keys / money / granting admin).
is_owner: owner,
manage_secrets: canManageSecrets(user),
@@@ -354,6 +356,8 @@ function getCapabilities(user) {
staff_tier: owner ? 'owner' : (isAdmin(user) ? 'admin' : (isGlobalMod(user) ? 'mod' : null)),
owned_channel_id: ownedChannel?.id || null,
moderated_channel_ids: moderatedChannels.map(ch => ch.id),
++ // Every staff capability this person holds (openvibe-contracts staff map), for the UI.
++ staff_caps: staff.capabilitiesOf(staffClaims(user)),
};
}
@@@ -400,7 +404,36 @@ function requireStreamer(req, res, next) {
next();
}
++// ── Staff capabilities (openvibe-contracts manifests/policy/staff-roles.json, ADR-022) ──────
++// The one question every staff gate asks: can(user, 'staff.<area>.<action>'). Network owns the roles;
++// Live's user row supplies them as the claims the map reads (role, and is_owner for the owner). Issued
++// staff_caps claims win once Network sends them. An unknown capability id throws (a typo is a bug).
++
++/** Live's user row as staff-map claims. */
++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;
++}
++
++/** Does `user` hold staff capability `capability`? */
++function can(user, capability) {
++ return !!user && staff.can(staffClaims(user), capability);
++}
++
++/** Express guard: 403 unless req.user holds `capability` (requireAuth runs first). */
++function requireCap(capability) {
++ staff.get(capability) || staff.can(null, capability); // fail at startup on an unknown id
++ return (req, res, next) => (can(req.user, capability) ? next() : res.status(403).json({ error: 'You do not have permission to do that' }));
++}
++
module.exports = {
++ // Staff capabilities (prefer these to the role helpers below)
++ can,
++ requireCap,
++ staffClaims,
++
// Role checks
isAdmin,
isOwner,
ddiff --git a/server/chat/chat-server.js b/server/chat/chat-server.js
iindex ce485567..a64062ec 100644
---- a/server/chat/chat-server.js
++++ b/server/chat/chat-server.js
@@@ -119,7 +119,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) {
const raw = req.headers?.['cf-connecting-ip']
@@@ -329,7 +329,7 @@ class ChatServer {
const user = authenticateWs(token);
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;
}
@@@ -1662,7 +1662,7 @@ class ChatServer {
const targetUser = db.getUserByUsername(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/live-context-routes.js b/server/chat/live-context-routes.js
iindex ed598ca1..fef87de7 100644
---- a/server/chat/live-context-routes.js
++++ b/server/chat/live-context-routes.js
@@@ -319,7 +319,7 @@ effectsRouter.post('/ban', (req, res) => {
const target = db.getUserById(int(b.user_id));
if (!target) return fail(res, 404, 'User not found');
// Prevent non-admins from banning admins
-- if (permissions.isGlobalModOrAbove(target) && target.role === 'admin' && actor.role !== 'admin') return fail(res, 403, 'You cannot ban an admin.');
++ if (permissions.isGlobalModOrAbove(target) && target.role === 'admin' && !permissions.isAdmin(actor)) return fail(res, 403, 'You cannot ban an admin.');
db.run('INSERT INTO bans (stream_id, user_id, reason, banned_by, expires_at) VALUES (?, ?, ?, ?, ?)',
[streamId, target.id, String(b.reason || 'Banned by moderator').slice(0, 200), actor.id, b.expires_at || null]);
} else if (b.ip_address) {
@@@ -378,7 +378,7 @@ effectsRouter.post('/ensure-channel', (req, res) => {
// TTS admin settings: admins; credentials only the owner (Live's /api/tts/admin/settings rules).
effectsRouter.post('/site-settings', (req, res) => {
const actor = actorOf(int(req.body?.actor_user_id));
-- if (!actor || actor.role !== 'admin') return fail(res, 403, 'Admin access required');
++ if (!permissions.can(actor, 'staff.site.configure')) return fail(res, 403, 'Admin access required');
const allowed = ['tts_enabled', 'tts_provider', 'tts_google_api_key', 'tts_google_service_account', 'tts_aws_access_key_id', 'tts_aws_secret_access_key', 'tts_aws_region', 'tts_max_length', 'tts_max_queue_per_user', 'tts_max_queue_global', 'tts_default_voice'];
const secret = new Set(['tts_google_api_key', 'tts_google_service_account', 'tts_aws_access_key_id', 'tts_aws_secret_access_key']);
const owner = permissions.isOwner(actor);
ddiff --git a/server/chat/sounds-routes.js b/server/chat/sounds-routes.js
iindex 2525bace..825127e2 100644
---- a/server/chat/sounds-routes.js
++++ b/server/chat/sounds-routes.js
@@@ -288,7 +288,7 @@ router.delete('/:id', requireAuth, (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 = db.getChannelByUserId(sound.channel_owner_id);
if (channel && permissions.canModerateChannel(req.user, channel.id)) allowed = true;
@@@ -327,7 +327,7 @@ router.patch('/command', requireAuth, (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/server/controls/onvif-routes.js b/server/controls/onvif-routes.js
iindex e6358346..9141b9f7 100644
---- a/server/controls/onvif-routes.js
++++ b/server/controls/onvif-routes.js
@@@ -16,6 +16,7 @@ const express = require('express');
const bcrypt = require('bcryptjs');
const crypto = require('crypto');
const db = require('../db/database');
++const { can } = require('../auth/permissions');
const { requireAuth } = require('../auth/auth');
const { OnvifDiscovery, OnvifClient } = require('../core/onvif-client');
@@@ -67,7 +68,7 @@ function decryptPassword(hash) {
// Discovery multicasts on the SERVER's network and tries admin/admin on whatever answers. A streamer's
// cameras are on their own network, never this one, so for anyone but staff this was only a way to
// scan the hosting provider's LAN. Admin only, with a bounded timeout.
--router.post('/discover', requireAuth, (req, res, next) => (req.user && req.user.role === 'admin' ? next() : res.status(403).json({ error: 'Camera discovery scans the server network and is limited to admins. Add your camera by address instead.' })), async (req, res) => {
++router.post('/discover', requireAuth, (req, res, next) => (can(req.user, 'staff.hardware.manage') ? next() : res.status(403).json({ error: 'Camera discovery scans the server network and is limited to admins. Add your camera by address instead.' })), async (req, res) => {
try {
const discovery = new OnvifDiscovery();
const devices = await discovery.discover(Math.min(Math.max(Number(req.body.timeout) || 3000, 500), 5000));
@@@ -144,7 +145,7 @@ router.post('/cameras', requireAuth, async (req, res) => {
if (!stream) {
return res.status(404).json({ error: 'Stream not found' });
}
-- if (stream.user_id !== req.user.id && req.user.role !== 'admin') {
++ if (stream.user_id !== req.user.id && !can(req.user, 'staff.hardware.manage')) {
return res.status(403).json({ error: 'Not your stream' });
}
}
@@@ -215,7 +216,7 @@ router.get('/cameras/:id', requireAuth, async (req, res) => {
return res.status(404).json({ error: 'Camera not found' });
}
-- if (camera.user_id !== req.user.id && req.user.role !== 'admin') {
++ if (camera.user_id !== req.user.id && !can(req.user, 'staff.hardware.manage')) {
return res.status(403).json({ error: 'Not your camera' });
}
@@@ -268,7 +269,7 @@ router.put('/cameras/:id', requireAuth, async (req, res) => {
return res.status(404).json({ error: 'Camera not found' });
}
-- if (camera.user_id !== req.user.id && req.user.role !== 'admin') {
++ if (camera.user_id !== req.user.id && !can(req.user, 'staff.hardware.manage')) {
return res.status(403).json({ error: 'Not your camera' });
}
@@@ -329,7 +330,7 @@ router.delete('/cameras/:id', requireAuth, (req, res) => {
return res.status(404).json({ error: 'Camera not found' });
}
-- if (camera.user_id !== req.user.id && req.user.role !== 'admin') {
++ if (camera.user_id !== req.user.id && !can(req.user, 'staff.hardware.manage')) {
return res.status(403).json({ error: 'Not your camera' });
}
@@@ -354,7 +355,7 @@ router.get('/cameras/:id/presets', requireAuth, (req, res) => {
return res.status(404).json({ error: 'Camera not found' });
}
-- if (camera.user_id !== req.user.id && req.user.role !== 'admin') {
++ if (camera.user_id !== req.user.id && !can(req.user, 'staff.hardware.manage')) {
return res.status(403).json({ error: 'Not your camera' });
}
@@@ -377,7 +378,7 @@ router.post('/cameras/:id/presets', requireAuth, async (req, res) => {
return res.status(404).json({ error: 'Camera not found' });
}
-- if (camera.user_id !== req.user.id && req.user.role !== 'admin') {
++ if (camera.user_id !== req.user.id && !can(req.user, 'staff.hardware.manage')) {
return res.status(403).json({ error: 'Not your camera' });
}
@@@ -424,7 +425,7 @@ router.delete('/cameras/:cameraId/presets/:presetId', requireAuth, (req, res) =>
return res.status(404).json({ error: 'Camera not found' });
}
-- if (camera.user_id !== req.user.id && req.user.role !== 'admin') {
++ if (camera.user_id !== req.user.id && !can(req.user, 'staff.hardware.manage')) {
return res.status(403).json({ error: 'Not your camera' });
}
ddiff --git a/server/controls/routes.js b/server/controls/routes.js
iindex bc86a692..db863475 100644
---- a/server/controls/routes.js
++++ b/server/controls/routes.js
@@@ -26,6 +26,7 @@ const express = require('express');
const crypto = require('crypto');
const bcrypt = require('bcryptjs');
const db = require('../db/database');
++const { can } = require('../auth/permissions');
const { requireAuth } = require('../auth/auth');
const router = express.Router();
@@@ -1019,7 +1020,7 @@ router.get('/configs/:id', requireAuth, (req, res) => {
try {
const config = db.getControlConfig(req.params.id);
if (!config) return res.status(404).json({ error: 'Config not found' });
-- if (config.user_id !== req.user.id && req.user.role !== 'admin') {
++ if (config.user_id !== req.user.id && !can(req.user, 'staff.hardware.manage')) {
return res.status(403).json({ error: 'Not authorized' });
}
const buttons = db.getConfigButtons(config.id);
@@@ -1034,7 +1035,7 @@ router.put('/configs/:id', requireAuth, (req, res) => {
try {
const config = db.getControlConfig(req.params.id);
if (!config) return res.status(404).json({ error: 'Config not found' });
-- if (config.user_id !== req.user.id && req.user.role !== 'admin') {
++ if (config.user_id !== req.user.id && !can(req.user, 'staff.hardware.manage')) {
return res.status(403).json({ error: 'Not authorized' });
}
const { name, description } = req.body;
@@@ -1054,7 +1055,7 @@ router.delete('/configs/:id', requireAuth, (req, res) => {
try {
const config = db.getControlConfig(req.params.id);
if (!config) return res.status(404).json({ error: 'Config not found' });
-- if (config.user_id !== req.user.id && req.user.role !== 'admin') {
++ if (config.user_id !== req.user.id && !can(req.user, 'staff.hardware.manage')) {
return res.status(403).json({ error: 'Not authorized' });
}
// Clear active config reference if it was active
@@@ -1074,7 +1075,7 @@ router.post('/configs/:id/buttons', requireAuth, (req, res) => {
try {
const config = db.getControlConfig(req.params.id);
if (!config) return res.status(404).json({ error: 'Config not found' });
-- if (config.user_id !== req.user.id && req.user.role !== 'admin') {
++ if (config.user_id !== req.user.id && !can(req.user, 'staff.hardware.manage')) {
return res.status(403).json({ error: 'Not authorized' });
}
@@@ -1124,7 +1125,7 @@ router.put('/configs/:id/buttons/:btnId', requireAuth, (req, res) => {
try {
const config = db.getControlConfig(req.params.id);
if (!config) return res.status(404).json({ error: 'Config not found' });
-- if (config.user_id !== req.user.id && req.user.role !== 'admin') {
++ if (config.user_id !== req.user.id && !can(req.user, 'staff.hardware.manage')) {
return res.status(403).json({ error: 'Not authorized' });
}
@@@ -1166,7 +1167,7 @@ router.delete('/configs/:id/buttons/:btnId', requireAuth, (req, res) => {
try {
const config = db.getControlConfig(req.params.id);
if (!config) return res.status(404).json({ error: 'Config not found' });
-- if (config.user_id !== req.user.id && req.user.role !== 'admin') {
++ if (config.user_id !== req.user.id && !can(req.user, 'staff.hardware.manage')) {
return res.status(403).json({ error: 'Not authorized' });
}
const removed = db.deleteConfigButton(parseInt(req.params.btnId), config.id);
@@@ -1184,7 +1185,7 @@ router.post('/configs/:id/activate', requireAuth, (req, res) => {
try {
const config = db.getControlConfig(req.params.id);
if (!config) return res.status(404).json({ error: 'Config not found' });
-- if (config.user_id !== req.user.id && req.user.role !== 'admin') {
++ if (config.user_id !== req.user.id && !can(req.user, 'staff.hardware.manage')) {
return res.status(403).json({ error: 'Not authorized' });
}
db.updateChannel(req.user.id, { active_control_config_id: config.id });
@@@ -1209,11 +1210,11 @@ router.post('/configs/:id/apply/:streamId', requireAuth, (req, res) => {
try {
const config = db.getControlConfig(req.params.id);
if (!config) return res.status(404).json({ error: 'Config not found' });
-- if (config.user_id !== req.user.id && req.user.role !== 'admin') {
++ if (config.user_id !== req.user.id && !can(req.user, 'staff.hardware.manage')) {
return res.status(403).json({ error: 'Not authorized' });
}
const stream = db.getStreamById(req.params.streamId);
-- if (!stream || (stream.user_id !== req.user.id && req.user.role !== 'admin')) {
++ if (!stream || (stream.user_id !== req.user.id && !can(req.user, 'staff.hardware.manage'))) {
return res.status(403).json({ error: 'Not authorized' });
}
const applied = db.applyConfigToStream(config.id, stream.id);
@@@ -1392,7 +1393,7 @@ router.get('/configs/:id/bridge-script', requireAuth, (req, res) => {
try {
const config = db.getControlConfig(req.params.id);
if (!config) return res.status(404).json({ error: 'Config not found' });
-- if (config.user_id !== req.user.id && req.user.role !== 'admin') {
++ if (config.user_id !== req.user.id && !can(req.user, 'staff.hardware.manage')) {
return res.status(403).json({ error: 'Not authorized' });
}
@@@ -1418,7 +1419,7 @@ router.get('/:streamId/config', requireAuth, (req, res) => {
try {
const stream = db.getStreamById(req.params.streamId);
if (!stream) return res.status(404).json({ error: 'Stream not found' });
-- if (stream.user_id !== req.user.id && req.user.role !== 'admin') {
++ if (stream.user_id !== req.user.id && !can(req.user, 'staff.hardware.manage')) {
return res.status(403).json({ error: 'Not authorized' });
}
res.json({ control_config_id: stream.control_config_id || null });
@@@ -1432,7 +1433,7 @@ router.put('/:streamId/config', requireAuth, (req, res) => {
try {
const stream = db.getStreamById(req.params.streamId);
if (!stream) return res.status(404).json({ error: 'Stream not found' });
-- if (stream.user_id !== req.user.id && req.user.role !== 'admin') {
++ if (stream.user_id !== req.user.id && !can(req.user, 'staff.hardware.manage')) {
return res.status(403).json({ error: 'Not authorized' });
}
@@@ -1440,7 +1441,7 @@ router.put('/:streamId/config', requireAuth, (req, res) => {
if (configId) {
const config = db.getControlConfig(configId);
if (!config) return res.status(404).json({ error: 'Config not found' });
-- if (config.user_id !== req.user.id && req.user.role !== 'admin') {
++ if (config.user_id !== req.user.id && !can(req.user, 'staff.hardware.manage')) {
return res.status(403).json({ error: 'Not authorized' });
}
const applied = db.applyConfigToStream(config.id, stream.id);
@@@ -1493,7 +1494,7 @@ const { applyCozmoPresets, removeCozmoPresets } = require('../integrations/cozmo
router.post('/:streamId/presets/cozmo', requireAuth, (req, res) => {
try {
const stream = db.getStreamById(req.params.streamId);
-- if (!stream || (stream.user_id !== req.user.id && req.user.role !== 'admin')) {
++ if (!stream || (stream.user_id !== req.user.id && !can(req.user, 'staff.hardware.manage'))) {
return res.status(403).json({ error: 'Not authorized' });
}
const result = applyCozmoPresets(parseInt(req.params.streamId));
@@@ -1507,7 +1508,7 @@ router.post('/:streamId/presets/cozmo', requireAuth, (req, res) => {
router.delete('/:streamId/presets/cozmo', requireAuth, (req, res) => {
try {
const stream = db.getStreamById(req.params.streamId);
-- if (!stream || (stream.user_id !== req.user.id && req.user.role !== 'admin')) {
++ if (!stream || (stream.user_id !== req.user.id && !can(req.user, 'staff.hardware.manage'))) {
return res.status(403).json({ error: 'Not authorized' });
}
const removed = removeCozmoPresets(parseInt(req.params.streamId));
ddiff --git a/server/emotes/routes.js b/server/emotes/routes.js
iindex f01efc50..7439fbe0 100644
---- a/server/emotes/routes.js
++++ b/server/emotes/routes.js
@@@ -348,7 +348,7 @@ router.post('/', requireAuth, emoteUpload.single('image'), (req, res) => {
}
const animated = req.file.mimetype === 'image/gif' || req.file.mimetype === 'image/webp';
-- const isGlobal = req.body.is_global === 'true' && req.user.role === 'admin';
++ const isGlobal = req.body.is_global === 'true' && permissions.can(req.user, 'staff.assets.manage');
// Per-emote display size (percent), clamped to the channel's configured range.
let sizeMin = 25, sizeMax = 400;
@@@ -417,7 +417,7 @@ router.patch('/:id', requireAuth, (req, res) => {
try {
const emote = db.getEmoteById(req.params.id);
if (!emote) return res.status(404).json({ error: 'Emote not found' });
-- let allowed = emote.user_id === req.user.id || req.user.role === 'admin';
++ let allowed = emote.user_id === req.user.id || permissions.can(req.user, 'staff.assets.manage');
if (!allowed) {
const ownerId = emote.channel_owner_id || emote.user_id;
const channel = db.getChannelByUserId(ownerId);
@@@ -475,7 +475,7 @@ router.delete('/:id', requireAuth, (req, res) => {
const emote = db.getEmoteById(req.params.id);
if (!emote) return res.status(404).json({ error: 'Emote not found' });
// Allowed: the uploader, an admin, or a mod/owner of the channel the emote belongs to.
-- let allowed = emote.user_id === req.user.id || req.user.role === 'admin';
++ let allowed = emote.user_id === req.user.id || permissions.can(req.user, 'staff.assets.manage');
if (!allowed) {
const ownerId = emote.channel_owner_id || emote.user_id;
const channel = db.getChannelByUserId(ownerId);
ddiff --git a/server/home/routes.js b/server/home/routes.js
iindex a5327183..b9badf50 100644
---- a/server/home/routes.js
++++ b/server/home/routes.js
@@@ -12,6 +12,7 @@
'use strict';
const express = require('express');
const db = require('../db/database');
++const { can } = require('../auth/permissions');
const media = require('../media-client');
const router = express.Router();
@@@ -548,7 +549,7 @@ router.get('/discover', optionalAuth, async (req, res) => {
// Admin: pick a new Star of OpenVibe right now (the job otherwise rotates daily).
router.post('/star/rotate', requireAuth, async (req, res) => {
-- if (!req.user || req.user.role !== 'admin') return res.status(403).json({ error: 'Admin only' });
++ if (!req.user || !can(req.user, 'staff.site.configure')) return res.status(403).json({ error: 'Admin only' });
try {
const out = await require('./star-job').rotate({ force: true });
_starCache = { at: 0, key: '', data: null };
ddiff --git a/server/index.js b/server/index.js
iindex ea5968dd..5fead426 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -418,8 +418,8 @@ function banRequestUser(req) {
return req._ovBanUser;
}
/** Admins (the site owner) pass IP / network bans — they may live on the same network as a banned person. */
--function isBanExemptAdmin(req) { const u = banRequestUser(req); return !!(u && !u.is_banned && u.role === 'admin'); }
--function isBanExemptAdminUser(u) { return !!(u && !u.is_banned && u.role === 'admin'); }
++function isBanExemptAdmin(req) { const u = banRequestUser(req); return !!(u && !u.is_banned && require('./auth/permissions').can(u, 'staff.limits.exempt')); }
++function isBanExemptAdminUser(u) { return !!(u && !u.is_banned && require('./auth/permissions').can(u, 'staff.limits.exempt')); }
/** Paths a banned network may still reach: health, the ban page's assets, SSO (so an admin can sign in), WHIP (stream-key auth, checks bans itself). */
function banPassPath(p) {
return p === '/api/health' || p.startsWith('/banned') || p.startsWith('/assets/') || p.startsWith('/api/auth/sso') || p === '/api/auth/callback' || p === '/api/auth/logout' || p.startsWith('/whip');
ddiff --git a/server/media-proxy/pastes.js b/server/media-proxy/pastes.js
iindex cca94e83..d115f4e6 100644
---- a/server/media-proxy/pastes.js
++++ b/server/media-proxy/pastes.js
@@@ -18,6 +18,7 @@
const express = require('express');
const multer = require('multer');
const db = require('../db/database');
++const { can } = require('../auth/permissions');
const media = require('../media-client');
const { requireAuth, optionalAuth, requireAdmin } = require('../auth/auth');
@@@ -268,7 +269,7 @@ function toCommunity(subPath) {
if (!sid) return res.status(409).json({ error: 'This account is not linked to an OpenVibe account yet. Sign in again and retry.' });
headers['X-OV-Subject'] = sid;
// Live staff moderate pastes as staff (Community checks our community.paste.moderate grant).
-- if (req.user.role === 'admin' || req.user.role === 'global_mod') headers['X-OV-Staff'] = '1';
++ if (can(req.user, 'staff.moderation.pastes')) headers['X-OV-Staff'] = '1';
}
if (req.ip) headers['X-Forwarded-For'] = req.ip;
const opts = { method: req.method, headers, redirect: 'manual', signal: AbortSignal.timeout(req.method === 'GET' ? 20000 : 120000) };
ddiff --git a/server/media-proxy/thumbnails.js b/server/media-proxy/thumbnails.js
iindex 1a79889b..2e4fe685 100644
---- a/server/media-proxy/thumbnails.js
++++ b/server/media-proxy/thumbnails.js
@@@ -12,6 +12,7 @@ const path = require('path');
const fs = require('fs');
const multer = require('multer');
const db = require('../db/database');
++const { can } = require('../auth/permissions');
const media = require('../media-client');
const access = require('./access');
const liveThumbs = require('./live-thumbs');
@@@ -32,7 +33,7 @@ router.post('/live/:streamId', requireAuth, upload.single('thumbnail'), (req, re
const streamId = parseInt(req.params.streamId);
const stream = db.getStreamById(streamId);
if (!stream) return res.status(404).json({ error: 'Stream not found' });
-- if (stream.user_id !== req.user.id && req.user.role !== 'admin') {
++ if (stream.user_id !== req.user.id && !can(req.user, 'staff.streams.manage')) {
return res.status(403).json({ error: 'Not your stream' });
}
if (!stream.is_live) return res.status(400).json({ error: 'Stream is not live' });
@@@ -54,7 +55,7 @@ async function generateFor(kind, id, req, res) {
const notFound = () => res.status(404).json({ error: `${kind === 'vod' ? 'VOD' : 'Clip'} not found` });
// A private item is missing to anyone who may not see it (a 403 would confirm it exists).
if (!meta || !access.canView(req.user, meta)) return notFound();
-- const canManage = !!req.user && (meta.user_id === req.user.id || req.user.role === 'admin');
++ const canManage = !!req.user && (meta.user_id === req.user.id || can(req.user, 'staff.streams.manage'));
const isPublic = meta.visibility ? meta.visibility === 'public' : !!meta.is_public;
if (!canManage && !isPublic) return res.status(403).json({ error: `Not your ${kind}` });
const out = await media.generateThumbnail(kind, id);
ddiff --git a/server/media-proxy/vods.js b/server/media-proxy/vods.js
iindex 163dab1f..f771c70d 100644
---- a/server/media-proxy/vods.js
++++ b/server/media-proxy/vods.js
@@@ -186,7 +186,7 @@ router.post('/stream/:streamId/chunk', requireAuth, memUpload.single('chunk'), a
if (!req.file) return res.status(400).json({ error: 'No chunk data' });
const stream = db.getStreamById(streamId);
if (!stream) return res.status(404).json({ error: 'Stream not found' });
-- if (stream.user_id !== req.user.id && req.user.role !== 'admin') {
++ if (stream.user_id !== req.user.id && !permissions.can(req.user, 'staff.streams.manage')) {
return res.status(403).json({ error: 'Not your stream' });
}
const vodPolicy = db.getChannelVodRecordingPolicyByUserId(stream.user_id, stream.managed_stream_id);
@@@ -239,7 +239,7 @@ router.post('/stream/:streamId/finalize', requireAuth, async (req, res) => {
const streamId = parseInt(req.params.streamId);
const stream = db.getStreamById(streamId);
if (!stream) return res.status(404).json({ error: 'Stream not found' });
-- if (stream.user_id !== req.user.id && req.user.role !== 'admin') {
++ if (stream.user_id !== req.user.id && !permissions.can(req.user, 'staff.streams.manage')) {
return res.status(403).json({ error: 'Not your stream' });
}
const rec = recorder.getActiveRecording(streamId);
@@@ -603,7 +603,7 @@ router.post('/upload', requireAuth, memUpload.single('video'), async (req, res)
const { stream_id, title } = req.body;
if (stream_id) {
const stream = db.getStreamById(stream_id);
-- if (stream && stream.user_id !== req.user.id && req.user.role !== 'admin') {
++ if (stream && stream.user_id !== req.user.id && !permissions.can(req.user, 'staff.streams.manage')) {
return res.status(403).json({ error: 'Not your stream' });
}
}
ddiff --git a/server/news/news-routes.js b/server/news/news-routes.js
iindex d499073a..43284d34 100644
---- a/server/news/news-routes.js
++++ b/server/news/news-routes.js
@@@ -9,7 +9,7 @@
const express = require('express');
const router = express.Router();
const { requireAuth } = require('../auth/auth');
--const { isOwner, isSensitiveSettingKey } = require('../auth/permissions');
++const { isOwner, isSensitiveSettingKey, can } = require('../auth/permissions');
const newsService = require('./news-service');
// Source `config` blobs can hold credentials (e.g. NewsAPI apiKey) that live
@@@ -33,7 +33,7 @@ function redactSourceConfig(source) {
// ── Get all news sources + their status (admin) ─────────────
router.get('/sources', requireAuth, (req, res) => {
-- if (req.user.role !== 'admin') return res.status(403).json({ error: 'Admin only' });
++ if (!can(req.user, 'staff.site.configure')) return res.status(403).json({ error: 'Admin only' });
let sources = newsService.getSources();
if (!isOwner(req.user)) sources = sources.map(redactSourceConfig);
res.json({ sources });
@@@ -41,7 +41,7 @@ router.get('/sources', requireAuth, (req, res) => {
// ── Update a news source config (admin) ─────────────────────
router.put('/sources/:id', requireAuth, (req, res) => {
-- if (req.user.role !== 'admin') return res.status(403).json({ error: 'Admin only' });
++ if (!can(req.user, 'staff.site.configure')) return res.status(403).json({ error: 'Admin only' });
try {
const { enabled } = req.body;
let { config } = req.body;
ddiff --git a/server/recap/routes.js b/server/recap/routes.js
iindex eace86a5..5f35ae58 100644
---- a/server/recap/routes.js
++++ b/server/recap/routes.js
@@@ -8,6 +8,7 @@
const express = require('express');
const router = express.Router();
const db = require('../db/database');
++const { can } = require('../auth/permissions');
const recap = require('./recap');
const { requireAuth, optionalAuth } = require('../auth/auth');
@@@ -45,7 +46,7 @@ router.post('/:streamId/regenerate', requireAuth, async (req, res) => {
const id = parseInt(req.params.streamId, 10);
const stream = db.getStreamById(id);
if (!stream) return res.status(404).json({ error: 'Stream not found' });
-- if (stream.user_id !== req.user.id && req.user.role !== 'admin') return res.status(403).json({ error: 'Not your stream' });
++ if (stream.user_id !== req.user.id && !can(req.user, 'staff.streams.manage')) return res.status(403).json({ error: 'Not your stream' });
const r = await recap.buildRecap(id);
if (!r) return res.status(409).json({ error: 'Stream is live or has no data yet' });
res.json({ recap: r });
ddiff --git a/server/streaming/analytics-routes.js b/server/streaming/analytics-routes.js
iindex abe3e347..d557fde2 100644
---- a/server/streaming/analytics-routes.js
++++ b/server/streaming/analytics-routes.js
@@@ -13,6 +13,7 @@
const express = require('express');
const db = require('../db/database');
++const { can } = require('../auth/permissions');
const { optionalAuth } = require('../auth/auth');
const router = express.Router();
@@@ -159,7 +160,7 @@ router.get('/channel/:username/dashboard', optionalAuth, (req, res) => {
if (!channel) return res.status(404).json({ error: 'Channel not found' });
// Only the channel owner (or admin) can see the dashboard
-- const isOwner = req.user && (req.user.id === channel.user_id || req.user.role === 'admin');
++ const isOwner = req.user && (req.user.id === channel.user_id || can(req.user, 'staff.streams.manage'));
if (!isOwner) return res.status(403).json({ error: 'Access denied' });
const days = Math.min(parseInt(req.query.days) || 30, 365);
ddiff --git a/server/streaming/call-server.js b/server/streaming/call-server.js
iindex e035ae90..cf5310dd 100644
---- a/server/streaming/call-server.js
++++ b/server/streaming/call-server.js
@@@ -118,7 +118,7 @@ class CallServer {
_canSeePrivate(ch, viewer) {
if (!ch.private) return true;
if (!viewer) return false;
-- if (viewer.role === 'admin' || viewer.role === 'global_mod') return true;
++ if (permissions.can(viewer, 'staff.moderation.calls')) return true;
return ch.createdBy === viewer.id || !!(ch.invited && ch.invited.has(viewer.id));
}
/** A caller invited this user to their call (POST /voice-channels/call-user). */
@@@ -198,7 +198,7 @@ class CallServer {
if (!ch || ch.permanent) return false;
if (ch.createdBy !== userId) {
const user = db.getUserById(userId);
-- if (!user || (user.role !== 'admin' && user.role !== 'global_mod')) return false;
++ if (!user || !permissions.can(user, 'staff.moderation.calls')) return false;
}
this.endCall(channelId);
this.channels.delete(channelId);
@@@ -296,7 +296,7 @@ class CallServer {
}
let fromIp = 0;
for (const [, info] of room) if (info.ip === ip) fromIp++;
-- if (fromIp >= MAX_SOCKETS_PER_IP && !(user && (user.role === 'admin' || user.role === 'global_mod'))) { ws.send(JSON.stringify({ type: 'error', message: 'Too many connections from your network' })); ws.close(); return; }
++ if (fromIp >= MAX_SOCKETS_PER_IP && !permissions.can(user, 'staff.limits.exempt')) { ws.send(JSON.stringify({ type: 'error', message: 'Too many connections from your network' })); ws.close(); return; }
const maxP = channel.maxParticipants || MAX_PARTICIPANTS;
if (room.size >= maxP) { ws.send(JSON.stringify({ type: 'error', message: `Channel full (max ${maxP})` })); ws.close(); return; }
@@@ -342,7 +342,7 @@ class CallServer {
_canModerate(user, channelId) {
if (!user) return false;
-- if (user.role === 'admin' || user.role === 'global_mod') return true;
++ if (permissions.can(user, 'staff.moderation.calls')) return true;
const ch = this.channels.get(channelId);
if (ch?.createdBy === user.id) return true;
if (ch?.streamId) return permissions.canModerateCall(user, ch.streamId);
@@@ -475,7 +475,7 @@ class CallServer {
case 'end-call': case 'end-channel': {
const c = room.get(peerId); if (!c) break;
const ch = this.channels.get(channelId);
-- const canEnd = (ch?.createdBy && c.user?.id === ch.createdBy) || c.isStreamer || (c.user && (c.user.role === 'admin' || c.user.role === 'global_mod'));
++ const canEnd = (ch?.createdBy && c.user?.id === ch.createdBy) || c.isStreamer || permissions.can(c.user, 'staff.moderation.calls');
if (canEnd) { this.endCall(channelId); if (ch && !ch.permanent) this.channels.delete(channelId); }
break;
}
ddiff --git a/server/streaming/restream-routes.js b/server/streaming/restream-routes.js
iindex 9035fd38..e66301b4 100644
---- a/server/streaming/restream-routes.js
++++ b/server/streaming/restream-routes.js
@@@ -8,6 +8,7 @@
const express = require('express');
const db = require('../db/database');
++const { can } = require('../auth/permissions');
const { requireAuth } = require('../auth/auth');
const restreamManager = require('./restream-manager');
const chatRelayService = require('../integrations/chat-relay-service');
@@@ -495,7 +496,7 @@ router.post('/viewer-counts', requireAuth, (req, res) => {
if (!Number.isFinite(destId) || (count != null && !Number.isFinite(count))) continue;
// Only your own destinations: this feeds external viewer totals and PowerChat view counts.
const dest = db.getRestreamDestinationById(destId);
-- if (!dest || (dest.user_id !== req.user.id && req.user.role !== 'admin')) continue;
++ if (!dest || (dest.user_id !== req.user.id && !can(req.user, 'staff.streams.manage'))) continue;
restreamManager.setViewerCount(destId, count);
}
res.json({ ok: true });
ddiff --git a/server/streaming/routes.js b/server/streaming/routes.js
iindex a3bbc552..abaf0eba 100644
---- a/server/streaming/routes.js
++++ b/server/streaming/routes.js
@@@ -23,6 +23,7 @@
*/
const express = require('express');
const db = require('../db/database');
++const { can } = require('../auth/permissions');
const config = require('../config');
const { requireAuth, requireStreamer, optionalAuth } = require('../auth/auth');
const jsmpegRelay = require('./jsmpeg-relay');
@@@ -94,7 +95,7 @@ const { publicManagedStream, publicChannel, publicStream } = require('../web/ser
* going live on a slot, heartbeats, call settings and WHIP publishing stay with the streamer alone.
*/
function staffMayModerate(actor, ownerUserId) {
-- if (!actor || actor.role !== 'admin') return false;
++ if (!can(actor, 'staff.streams.manage')) return false;
const target = db.getUserById(ownerUserId);
return !(target && target.is_owner);
}
@@@ -104,7 +105,7 @@ function ownControlConfigId(req, raw) {
if (raw === null || raw === undefined || raw === '') return null;
const id = parseInt(raw);
const cfg = Number.isFinite(id) ? db.getControlConfig(id) : null;
-- if (!cfg || (cfg.user_id !== req.user.id && req.user.role !== 'admin')) return undefined;
++ if (!cfg || (cfg.user_id !== req.user.id && !can(req.user, 'staff.streams.manage'))) return undefined;
return id;
}
@@@ -251,7 +252,7 @@ router.get('/channel/:username', optionalAuth, async (req, res) => {
// Show hidden (private + unlisted) VODs/clips to the channel owner AND admins;
// everyone else sees public only.
const isOwner = req.user && req.user.id === channel.user_id;
-- const canSeeHidden = !!(isOwner || (req.user && (req.user.role === 'admin' || req.user.capabilities?.moderate_global)));
++ const canSeeHidden = !!(isOwner || can(req.user, 'staff.content.view_private'));
const vodLimit = Math.min(Math.max(parseInt(req.query.vodLimit || '12', 10), 1), 48);
const vodOffset = Math.max(parseInt(req.query.vodOffset || '0', 10), 0);
const ALLOWED_VOD_ORDERS = new Set(['newest', 'oldest', 'views', 'peak_viewers']);
@@@ -594,7 +595,7 @@ router.get('/channel/:username/clips-taken', optionalAuth, async (req, res) => {
const user = db.getUserByUsername(req.params.username);
if (!user) return res.status(404).json({ error: 'Not found' });
const isOwner = req.user && req.user.id === user.id;
-- const canSeeHidden = !!(isOwner || (req.user && (req.user.role === 'admin' || req.user.capabilities?.moderate_global)));
++ const canSeeHidden = !!(isOwner || can(req.user, 'staff.content.view_private'));
const ALLOWED_SORT = new Set(['newest', 'oldest', 'views']);
const orderBy = ALLOWED_SORT.has(req.query.sort) ? req.query.sort : 'newest';
const sourceStreamerId = parseInt(req.query.of, 10) || null;
ddiff --git a/server/themes/routes.js b/server/themes/routes.js
iindex 4f149ca7..23f7e216 100644
---- a/server/themes/routes.js
++++ b/server/themes/routes.js
@@@ -14,6 +14,7 @@
const express = require('express');
const router = express.Router();
const { requireAuth, optionalAuth } = require('../auth/auth');
++const { can } = require('../auth/permissions');
const themeService = require('./theme-service');
/* ── List all public themes ────────────────────────────────── */
@@@ -146,7 +147,7 @@ router.put('/:id', requireAuth, (req, res) => {
/* ── Delete community theme ────────────────────────────────── */
router.delete('/:id', requireAuth, (req, res) => {
try {
-- const isAdmin = req.user.role === 'admin';
++ const isAdmin = can(req.user, 'staff.assets.manage');
themeService.deleteTheme(parseInt(req.params.id), req.user.id, isAdmin);
res.json({ success: true });
} catch (err) {
ddiff --git a/test/staff-capabilities.test.js b/test/staff-capabilities.test.js
nnew file mode 100644
iindex 00000000..222693b9
---- /dev/null
++++ b/test/staff-capabilities.test.js
@@@ -0,0 +1,93 @@
++'use strict';
++/**
++ * Staff gates ask the openvibe-contracts staff map (ADR-022): permissions.can(user, 'staff.<area>.<action>').
++ * The role helpers answer exactly what the map says for every role, and no server file compares an
++ * actor's role by hand any more (the few left compare a TARGET's rank, listed below).
++ */
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++
++process.env.DB_PATH = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'live-staff-')), 'test.db');
++process.env.NODE_ENV = 'test';
++const log = console.log;
++console.log = () => {};
++const { staff } = require('openvibe-contracts');
++const db = require('../server/db/database');
++if (db.initDb) db.initDb();
++const p = require('../server/auth/permissions');
++
++const people = {
++ anonymous: null,
++ user: { id: 901, role: 'user' },
++ streamer: { id: 902, role: 'streamer' },
++ global_mod: { id: 903, role: 'global_mod' },
++ admin: { id: 904, role: 'admin' },
++ owner: { id: 905, role: 'admin', is_owner: 1 },
++};
++const HELPERS = {
++ canAccessAdminPanel: 'staff.console.access', canManageUsers: 'staff.users.manage', canManageGlobalMods: 'staff.roles.assign',
++ canManageSiteSettings: 'staff.site.configure', canManageSecrets: 'staff.secrets.manage', canManageMoney: 'staff.money.freeze',
++ canGrantAdmin: 'staff.roles.grant_admin', canReviewCashouts: 'staff.money.cashouts', canReviewVpn: 'staff.moderation.vpn',
++ canManageSiteBans: 'staff.moderation.bans', canViewOtherUserLogs: 'staff.moderation.logs', canForceEndStreams: 'staff.streams.end',
++};
++for (const [name, u] of Object.entries(people)) {
++ const map = new Set(u ? 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}`);
++ for (const [h, cap] of Object.entries(HELPERS)) assert.strictEqual(!!p[h](u), map.has(cap), `${name}: ${h} answers the map (${cap})`);
++}
++assert.strictEqual(p.can(people.admin, 'staff.secrets.manage'), false, 'an admin is not the owner');
++assert.strictEqual(p.can(people.owner, 'staff.secrets.manage'), true);
++assert.strictEqual(p.can(people.global_mod, 'staff.hardware.manage'), false);
++assert.strictEqual(p.can({ id: 906, role: 'user', staff_caps: ['staff.moderation.*'] }, 'staff.moderation.chat'), true, 'issued claims win');
++assert.throws(() => p.can(people.admin, 'staff.nope'), /unknown staff capability/);
++assert.throws(() => p.requireCap('staff.nope'), /unknown staff capability/);
++
++// requireCap as middleware.
++const res = () => ({ code: 200, status(c) { this.code = c; return this; }, json() { return this; } });
++let r = res(), passed = false;
++p.requireCap('staff.hardware.manage')({ user: people.global_mod }, r, () => { passed = true; });
++assert.strictEqual(r.code, 403); assert.strictEqual(passed, false);
++p.requireCap('staff.hardware.manage')({ user: people.admin }, res(), () => { passed = true; });
++assert.strictEqual(passed, true);
++
++// The UI gets the same answers.
++const caps = p.getCapabilities(people.global_mod);
++assert.deepStrictEqual(caps.staff_caps, staff.capabilitiesOf('global_mod'));
++assert.strictEqual(caps.view_ip_info, true);
++assert.deepStrictEqual(p.getCapabilities(null).staff_caps, []);
++
++// No raw actor-role comparisons in server code. What is left compares a target's rank or maps role data.
++const TARGET_RANK = [
++ ['internal/routes.js', "user.is_owner && role !== 'admin'"],
++ ['chat/live-context-routes.js', "target.role === 'admin'"],
++ ['chat/chat-server.js', "targetUser.role === 'admin'"],
++ ['admin/routes.js', "target.role === 'admin'"],
++ ['admin/routes.js', "banTarget.role === 'admin'"],
++ ['admin/routes.js', "if (user.role === 'admin') return res.status(400)"],
++ ['admin/routes.js', "if (user.role === 'global_mod') return res.status(400)"],
++ ['admin/routes.js', "if (user.role !== 'global_mod') return res.status(400)"],
++ ['admin/routes.js', "if (user.role !== 'admin') return res.status(400)"],
++ ['db/database.js', "row.role === 'admin' || row.role === 'global_mod'"],
++];
++const RAW = /role\s*[!=]==?\s*'(admin|global_mod)'/;
++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 (!RAW.test(line)) return;
++ if (TARGET_RANK.some(([file, snip]) => file === rel && line.includes(snip))) return;
++ offenders.push(`server/${rel}:${i + 1}: ${line.trim().slice(0, 120)}`);
++ });
++ }
++})(path.join(__dirname, '..', 'server'));
++assert.deepStrictEqual(offenders, [], `raw role checks; use permissions.can(user, 'staff.…'):\n${offenders.join('\n')}`);
++
++log('staff capabilities: all checks passed');
++process.exit(0);