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
Changes
@@@ -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",@@@ -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",@@@ -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();@@@ -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 checksisAdmin,isOwner,@@@ -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;}@@@ -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);@@@ -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.` });}@@@ -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' });}@@@ -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/cozmorouter.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));@@@ -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);@@@ -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 };@@@ -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');@@@ -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) };@@@ -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, reconst 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);@@@ -186,7 +186,7 @@ router.post('/stream/:streamId/chunk', requireAuth, memUpload.single('chunk'), aif (!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' });}}@@@ -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;@@@ -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 });@@@ -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);@@@ -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;}@@@ -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 });@@@ -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;@@@ -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) {nnew file mode 100644@@@ -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);
@@ -25,7 +25,7 @@
@@ -25,7 +25,7 @@
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
"node-media-server": "^2.7.1",
"node-media-server": "^2.7.1",
"onnxruntime-node": "^1.20.0",
"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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.6.0",
"sanitize-html": "^2.17.7",
"sanitize-html": "^2.17.7",
@@ -2814,9 +2814,9 @@
@@ -2814,9 +2814,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",
@@ -43,7 +43,7 @@
@@ -43,7 +43,7 @@
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
"node-media-server": "^2.7.1",
"node-media-server": "^2.7.1",
"onnxruntime-node": "^1.20.0",
"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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.6.0",
"sanitize-html": "^2.17.7",
"sanitize-html": "^2.17.7",
@@ -300,7 +300,7 @@ function optionalAuth(req, res, next) {
@@ -300,7 +300,7 @@ 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();
@@ -14,6 +14,7 @@
@@ -14,6 +14,7 @@
*/
*/
const db = require('../db/database');
const db = require('../db/database');
+const { staff } = require('openvibe-contracts');
// ── Role hierarchy (higher = more power) ─────────────────────
// ── Role hierarchy (higher = more power) ─────────────────────
const ROLE_RANK = {
const ROLE_RANK = {
@@ -154,62 +155,62 @@ function getChannelIdForStream(streamId) {
@@ -154,62 +155,62 @@ function getChannelIdForStream(streamId) {
* Global mods see a subset of tabs (chat logs, bans).
* Global mods see a subset of tabs (chat logs, bans).
*/
*/
function canAccessAdminPanel(user) {
function canAccessAdminPanel(user) {
- return isGlobalModOrAbove(user);
+ return can(user, 'staff.console.access');
}
}
/**
/**
* Can this user manage users (role changes, bans, etc.)? (admin only)
* Can this user manage users (role changes, bans, etc.)? (admin only)
*/
*/
function canManageUsers(user) {
function canManageUsers(user) {
- return isAdmin(user);
+ return can(user, 'staff.users.manage');
}
}
/**
/**
* Can this user manage (promote/demote) global mods? (admin only)
* Can this user manage (promote/demote) global mods? (admin only)
*/
*/
function canManageGlobalMods(user) {
function canManageGlobalMods(user) {
- return isAdmin(user);
+ return can(user, 'staff.roles.assign');
}
}
/**
/**
* Can this user manage site settings? (admin only)
* Can this user manage site settings? (admin only)
*/
*/
function canManageSiteSettings(user) {
function canManageSiteSettings(user) {
- return isAdmin(user);
+ return can(user, 'staff.site.configure');
}
}
/** Can this user view/change API keys + other secret settings? (owner only) */
/** Can this user view/change API keys + other secret settings? (owner only) */
function canManageSecrets(user) {
function canManageSecrets(user) {
- return isOwner(user);
+ return can(user, 'staff.secrets.manage');
}
}
/** Can this user manage money — payments config, cashouts/payouts, funds? (owner only) */
/** Can this user manage money — payments config, cashouts/payouts, funds? (owner only) */
function canManageMoney(user) {
function canManageMoney(user) {
- return isOwner(user);
+ return can(user, 'staff.money.freeze');
}
}
/** Can this user grant/revoke the ADMIN role? (owner only) */
/** Can this user grant/revoke the ADMIN role? (owner only) */
function canGrantAdmin(user) {
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.
* Can this user review cashouts? Cashouts move real money out — owner only.
*/
*/
function canReviewCashouts(user) {
function canReviewCashouts(user) {
- return isOwner(user);
+ return can(user, 'staff.money.cashouts');
}
}
/**
/**
* Can this user review VPN queue? (admin only)
* Can this user review VPN queue? (admin only)
*/
*/
function canReviewVpn(user) {
function canReviewVpn(user) {
- return isAdmin(user);
+ return can(user, 'staff.moderation.vpn');
}
}
/**
/**
* Can this user manage site-wide bans? (admin + global_mod)
* Can this user manage site-wide bans? (admin + global_mod)
*/
*/
function canManageSiteBans(user) {
function canManageSiteBans(user) {
- return isGlobalModOrAbove(user);
+ return can(user, 'staff.moderation.bans');
}
}
/**
/**
@@ -219,7 +220,7 @@ function canManageSiteBans(user) {
@@ -219,7 +220,7 @@ function canManageSiteBans(user) {
*/
*/
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);
}
}
@@ -231,7 +232,7 @@ function canModerateChannel(user, channelId) {
@@ -231,7 +232,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;
@@ -255,7 +256,7 @@ function canModerateCall(user, streamId) {
@@ -255,7 +256,7 @@ function canModerateCall(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';
}
}
@@ -263,7 +264,7 @@ function canViewChatLogs(user, scope = 'own') {
@@ -263,7 +264,7 @@ 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');
}
}
/**
/**
@@ -273,7 +274,7 @@ function canViewOtherUserLogs(user) {
@@ -273,7 +274,7 @@ function canViewOtherUserLogs(user) {
*/
*/
function canAssignChannelMods(user, channelId) {
function canAssignChannelMods(user, channelId) {
if (!user) return false;
if (!user) return false;
- if (isAdmin(user)) return true;
+ if (can(user, 'staff.roles.assign')) return true;
return isChannelOwner(user, channelId);
return isChannelOwner(user, channelId);
}
}
@@ -288,7 +289,7 @@ function canManageOwnChannel(user) {
@@ -288,7 +289,7 @@ function canManageOwnChannel(user) {
* Can this user force-end any stream? (admin only)
* Can this user force-end any stream? (admin only)
*/
*/
function canForceEndStreams(user) {
function canForceEndStreams(user) {
- return isAdmin(user);
+ return can(user, 'staff.streams.end');
}
}
// ── Capability map (returned to frontend via /api/auth/me) ───
// ── Capability map (returned to frontend via /api/auth/me) ───
@@ -319,6 +320,7 @@ function getCapabilities(user) {
@@ -319,6 +320,7 @@ function getCapabilities(user) {
view_ip_info: false,
view_ip_info: false,
owned_channel_id: null,
owned_channel_id: null,
moderated_channel_ids: [],
moderated_channel_ids: [],
+ staff_caps: [],
};
};
}
}
@@ -344,7 +346,7 @@ function getCapabilities(user) {
@@ -344,7 +346,7 @@ function getCapabilities(user) {
can_access_staff_console: isStaffUser,
can_access_staff_console: isStaffUser,
can_manage_channels: !!ownedChannel || moderatedChannels.length > 0 || isStaffUser,
can_manage_channels: !!ownedChannel || moderatedChannels.length > 0 || isStaffUser,
can_moderate_site_chat: 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).
// Owner-only powers (keys / money / granting admin).
is_owner: owner,
is_owner: owner,
manage_secrets: canManageSecrets(user),
manage_secrets: canManageSecrets(user),
@@ -354,6 +356,8 @@ function getCapabilities(user) {
@@ -354,6 +356,8 @@ function getCapabilities(user) {
staff_tier: owner ? 'owner' : (isAdmin(user) ? 'admin' : (isGlobalMod(user) ? 'mod' : null)),
staff_tier: owner ? 'owner' : (isAdmin(user) ? 'admin' : (isGlobalMod(user) ? 'mod' : null)),
owned_channel_id: ownedChannel?.id || null,
owned_channel_id: ownedChannel?.id || null,
moderated_channel_ids: moderatedChannels.map(ch => ch.id),
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) {
@@ -400,7 +404,36 @@ function requireStreamer(req, res, next) {
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 = {
module.exports = {
+ // Staff capabilities (prefer these to the role helpers below)
+ can,
+ requireCap,
+ staffClaims,
+
// Role checks
// Role checks
isAdmin,
isAdmin,
isOwner,
isOwner,
@@ -119,7 +119,7 @@ class ChatServer {
@@ -119,7 +119,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) {
const raw = req.headers?.['cf-connecting-ip']
const raw = req.headers?.['cf-connecting-ip']
@@ -329,7 +329,7 @@ class ChatServer {
@@ -329,7 +329,7 @@ class ChatServer {
const user = authenticateWs(token);
const user = authenticateWs(token);
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;
}
}
@@ -1662,7 +1662,7 @@ class ChatServer {
@@ -1662,7 +1662,7 @@ class ChatServer {
const targetUser = db.getUserByUsername(target);
const targetUser = db.getUserByUsername(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;
}
}
@@ -319,7 +319,7 @@ effectsRouter.post('/ban', (req, res) => {
@@ -319,7 +319,7 @@ effectsRouter.post('/ban', (req, res) => {
const target = db.getUserById(int(b.user_id));
const target = db.getUserById(int(b.user_id));
if (!target) return fail(res, 404, 'User not found');
if (!target) return fail(res, 404, 'User not found');
// Prevent non-admins from banning admins
// 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 (?, ?, ?, ?, ?)',
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]);
[streamId, target.id, String(b.reason || 'Banned by moderator').slice(0, 200), actor.id, b.expires_at || null]);
} else if (b.ip_address) {
} else if (b.ip_address) {
@@ -378,7 +378,7 @@ effectsRouter.post('/ensure-channel', (req, res) => {
@@ -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).
// TTS admin settings: admins; credentials only the owner (Live's /api/tts/admin/settings rules).
effectsRouter.post('/site-settings', (req, res) => {
effectsRouter.post('/site-settings', (req, res) => {
const actor = actorOf(int(req.body?.actor_user_id));
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 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 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);
const owner = permissions.isOwner(actor);
@@ -288,7 +288,7 @@ router.delete('/:id', requireAuth, (req, res) => {
@@ -288,7 +288,7 @@ router.delete('/:id', requireAuth, (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 = db.getChannelByUserId(sound.channel_owner_id);
const channel = db.getChannelByUserId(sound.channel_owner_id);
if (channel && permissions.canModerateChannel(req.user, channel.id)) allowed = true;
if (channel && permissions.canModerateChannel(req.user, channel.id)) allowed = true;
@@ -327,7 +327,7 @@ router.patch('/command', requireAuth, (req, res) => {
@@ -327,7 +327,7 @@ router.patch('/command', requireAuth, (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.` });
}
}
@@ -16,6 +16,7 @@ const express = require('express');
@@ -16,6 +16,7 @@ const express = require('express');
const bcrypt = require('bcryptjs');
const bcrypt = require('bcryptjs');
const crypto = require('crypto');
const crypto = require('crypto');
const db = require('../db/database');
const db = require('../db/database');
+const { can } = require('../auth/permissions');
const { requireAuth } = require('../auth/auth');
const { requireAuth } = require('../auth/auth');
const { OnvifDiscovery, OnvifClient } = require('../core/onvif-client');
const { OnvifDiscovery, OnvifClient } = require('../core/onvif-client');
@@ -67,7 +68,7 @@ function decryptPassword(hash) {
@@ -67,7 +68,7 @@ function decryptPassword(hash) {
// Discovery multicasts on the SERVER's network and tries admin/admin on whatever answers. A streamer's
// 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
// 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.
// 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 {
try {
const discovery = new OnvifDiscovery();
const discovery = new OnvifDiscovery();
const devices = await discovery.discover(Math.min(Math.max(Number(req.body.timeout) || 3000, 500), 5000));
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) => {
@@ -144,7 +145,7 @@ router.post('/cameras', requireAuth, async (req, res) => {
if (!stream) {
if (!stream) {
return res.status(404).json({ error: 'Stream not found' });
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' });
return res.status(403).json({ error: 'Not your stream' });
}
}
}
}
@@ -215,7 +216,7 @@ router.get('/cameras/:id', requireAuth, async (req, res) => {
@@ -215,7 +216,7 @@ router.get('/cameras/:id', requireAuth, async (req, res) => {
return res.status(404).json({ error: 'Camera not found' });
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' });
return res.status(403).json({ error: 'Not your camera' });
}
}
@@ -268,7 +269,7 @@ router.put('/cameras/:id', requireAuth, async (req, res) => {
@@ -268,7 +269,7 @@ router.put('/cameras/:id', requireAuth, async (req, res) => {
return res.status(404).json({ error: 'Camera not found' });
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' });
return res.status(403).json({ error: 'Not your camera' });
}
}
@@ -329,7 +330,7 @@ router.delete('/cameras/:id', requireAuth, (req, res) => {
@@ -329,7 +330,7 @@ router.delete('/cameras/:id', requireAuth, (req, res) => {
return res.status(404).json({ error: 'Camera not found' });
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' });
return res.status(403).json({ error: 'Not your camera' });
}
}
@@ -354,7 +355,7 @@ router.get('/cameras/:id/presets', requireAuth, (req, res) => {
@@ -354,7 +355,7 @@ router.get('/cameras/:id/presets', requireAuth, (req, res) => {
return res.status(404).json({ error: 'Camera not found' });
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' });
return res.status(403).json({ error: 'Not your camera' });
}
}
@@ -377,7 +378,7 @@ router.post('/cameras/:id/presets', requireAuth, async (req, res) => {
@@ -377,7 +378,7 @@ router.post('/cameras/:id/presets', requireAuth, async (req, res) => {
return res.status(404).json({ error: 'Camera not found' });
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' });
return res.status(403).json({ error: 'Not your camera' });
}
}
@@ -424,7 +425,7 @@ router.delete('/cameras/:cameraId/presets/:presetId', requireAuth, (req, res) =>
@@ -424,7 +425,7 @@ router.delete('/cameras/:cameraId/presets/:presetId', requireAuth, (req, res) =>
return res.status(404).json({ error: 'Camera not found' });
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' });
return res.status(403).json({ error: 'Not your camera' });
}
}
@@ -26,6 +26,7 @@ const express = require('express');
@@ -26,6 +26,7 @@ const express = require('express');
const crypto = require('crypto');
const crypto = require('crypto');
const bcrypt = require('bcryptjs');
const bcrypt = require('bcryptjs');
const db = require('../db/database');
const db = require('../db/database');
+const { can } = require('../auth/permissions');
const { requireAuth } = require('../auth/auth');
const { requireAuth } = require('../auth/auth');
const router = express.Router();
const router = express.Router();
@@ -1019,7 +1020,7 @@ router.get('/configs/:id', requireAuth, (req, res) => {
@@ -1019,7 +1020,7 @@ router.get('/configs/:id', requireAuth, (req, res) => {
try {
try {
const config = db.getControlConfig(req.params.id);
const config = db.getControlConfig(req.params.id);
if (!config) return res.status(404).json({ error: 'Config not found' });
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' });
return res.status(403).json({ error: 'Not authorized' });
}
}
const buttons = db.getConfigButtons(config.id);
const buttons = db.getConfigButtons(config.id);
@@ -1034,7 +1035,7 @@ router.put('/configs/:id', requireAuth, (req, res) => {
@@ -1034,7 +1035,7 @@ router.put('/configs/:id', requireAuth, (req, res) => {
try {
try {
const config = db.getControlConfig(req.params.id);
const config = db.getControlConfig(req.params.id);
if (!config) return res.status(404).json({ error: 'Config not found' });
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' });
return res.status(403).json({ error: 'Not authorized' });
}
}
const { name, description } = req.body;
const { name, description } = req.body;
@@ -1054,7 +1055,7 @@ router.delete('/configs/:id', requireAuth, (req, res) => {
@@ -1054,7 +1055,7 @@ router.delete('/configs/:id', requireAuth, (req, res) => {
try {
try {
const config = db.getControlConfig(req.params.id);
const config = db.getControlConfig(req.params.id);
if (!config) return res.status(404).json({ error: 'Config not found' });
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' });
return res.status(403).json({ error: 'Not authorized' });
}
}
// Clear active config reference if it was active
// Clear active config reference if it was active
@@ -1074,7 +1075,7 @@ router.post('/configs/:id/buttons', requireAuth, (req, res) => {
@@ -1074,7 +1075,7 @@ router.post('/configs/:id/buttons', requireAuth, (req, res) => {
try {
try {
const config = db.getControlConfig(req.params.id);
const config = db.getControlConfig(req.params.id);
if (!config) return res.status(404).json({ error: 'Config not found' });
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' });
return res.status(403).json({ error: 'Not authorized' });
}
}
@@ -1124,7 +1125,7 @@ router.put('/configs/:id/buttons/:btnId', requireAuth, (req, res) => {
@@ -1124,7 +1125,7 @@ router.put('/configs/:id/buttons/:btnId', requireAuth, (req, res) => {
try {
try {
const config = db.getControlConfig(req.params.id);
const config = db.getControlConfig(req.params.id);
if (!config) return res.status(404).json({ error: 'Config not found' });
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' });
return res.status(403).json({ error: 'Not authorized' });
}
}
@@ -1166,7 +1167,7 @@ router.delete('/configs/:id/buttons/:btnId', requireAuth, (req, res) => {
@@ -1166,7 +1167,7 @@ router.delete('/configs/:id/buttons/:btnId', requireAuth, (req, res) => {
try {
try {
const config = db.getControlConfig(req.params.id);
const config = db.getControlConfig(req.params.id);
if (!config) return res.status(404).json({ error: 'Config not found' });
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' });
return res.status(403).json({ error: 'Not authorized' });
}
}
const removed = db.deleteConfigButton(parseInt(req.params.btnId), config.id);
const removed = db.deleteConfigButton(parseInt(req.params.btnId), config.id);
@@ -1184,7 +1185,7 @@ router.post('/configs/:id/activate', requireAuth, (req, res) => {
@@ -1184,7 +1185,7 @@ router.post('/configs/:id/activate', requireAuth, (req, res) => {
try {
try {
const config = db.getControlConfig(req.params.id);
const config = db.getControlConfig(req.params.id);
if (!config) return res.status(404).json({ error: 'Config not found' });
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' });
return res.status(403).json({ error: 'Not authorized' });
}
}
db.updateChannel(req.user.id, { active_control_config_id: config.id });
db.updateChannel(req.user.id, { active_control_config_id: config.id });
@@ -1209,11 +1210,11 @@ router.post('/configs/:id/apply/:streamId', requireAuth, (req, res) => {
@@ -1209,11 +1210,11 @@ router.post('/configs/:id/apply/:streamId', requireAuth, (req, res) => {
try {
try {
const config = db.getControlConfig(req.params.id);
const config = db.getControlConfig(req.params.id);
if (!config) return res.status(404).json({ error: 'Config not found' });
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' });
return res.status(403).json({ error: 'Not authorized' });
}
}
const stream = db.getStreamById(req.params.streamId);
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' });
return res.status(403).json({ error: 'Not authorized' });
}
}
const applied = db.applyConfigToStream(config.id, stream.id);
const applied = db.applyConfigToStream(config.id, stream.id);
@@ -1392,7 +1393,7 @@ router.get('/configs/:id/bridge-script', requireAuth, (req, res) => {
@@ -1392,7 +1393,7 @@ router.get('/configs/:id/bridge-script', requireAuth, (req, res) => {
try {
try {
const config = db.getControlConfig(req.params.id);
const config = db.getControlConfig(req.params.id);
if (!config) return res.status(404).json({ error: 'Config not found' });
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' });
return res.status(403).json({ error: 'Not authorized' });
}
}
@@ -1418,7 +1419,7 @@ router.get('/:streamId/config', requireAuth, (req, res) => {
@@ -1418,7 +1419,7 @@ router.get('/:streamId/config', requireAuth, (req, res) => {
try {
try {
const stream = db.getStreamById(req.params.streamId);
const stream = db.getStreamById(req.params.streamId);
if (!stream) return res.status(404).json({ error: 'Stream not found' });
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' });
return res.status(403).json({ error: 'Not authorized' });
}
}
res.json({ control_config_id: stream.control_config_id || null });
res.json({ control_config_id: stream.control_config_id || null });
@@ -1432,7 +1433,7 @@ router.put('/:streamId/config', requireAuth, (req, res) => {
@@ -1432,7 +1433,7 @@ router.put('/:streamId/config', requireAuth, (req, res) => {
try {
try {
const stream = db.getStreamById(req.params.streamId);
const stream = db.getStreamById(req.params.streamId);
if (!stream) return res.status(404).json({ error: 'Stream not found' });
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' });
return res.status(403).json({ error: 'Not authorized' });
}
}
@@ -1440,7 +1441,7 @@ router.put('/:streamId/config', requireAuth, (req, res) => {
@@ -1440,7 +1441,7 @@ router.put('/:streamId/config', requireAuth, (req, res) => {
if (configId) {
if (configId) {
const config = db.getControlConfig(configId);
const config = db.getControlConfig(configId);
if (!config) return res.status(404).json({ error: 'Config not found' });
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' });
return res.status(403).json({ error: 'Not authorized' });
}
}
const applied = db.applyConfigToStream(config.id, stream.id);
const applied = db.applyConfigToStream(config.id, stream.id);
@@ -1493,7 +1494,7 @@ const { applyCozmoPresets, removeCozmoPresets } = require('../integrations/cozmo
@@ -1493,7 +1494,7 @@ const { applyCozmoPresets, removeCozmoPresets } = require('../integrations/cozmo
router.post('/:streamId/presets/cozmo', requireAuth, (req, res) => {
router.post('/:streamId/presets/cozmo', requireAuth, (req, res) => {
try {
try {
const stream = db.getStreamById(req.params.streamId);
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' });
return res.status(403).json({ error: 'Not authorized' });
}
}
const result = applyCozmoPresets(parseInt(req.params.streamId));
const result = applyCozmoPresets(parseInt(req.params.streamId));
@@ -1507,7 +1508,7 @@ router.post('/:streamId/presets/cozmo', requireAuth, (req, res) => {
@@ -1507,7 +1508,7 @@ router.post('/:streamId/presets/cozmo', requireAuth, (req, res) => {
router.delete('/:streamId/presets/cozmo', requireAuth, (req, res) => {
router.delete('/:streamId/presets/cozmo', requireAuth, (req, res) => {
try {
try {
const stream = db.getStreamById(req.params.streamId);
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' });
return res.status(403).json({ error: 'Not authorized' });
}
}
const removed = removeCozmoPresets(parseInt(req.params.streamId));
const removed = removeCozmoPresets(parseInt(req.params.streamId));
@@ -348,7 +348,7 @@ router.post('/', requireAuth, emoteUpload.single('image'), (req, res) => {
@@ -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 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.
// Per-emote display size (percent), clamped to the channel's configured range.
let sizeMin = 25, sizeMax = 400;
let sizeMin = 25, sizeMax = 400;
@@ -417,7 +417,7 @@ router.patch('/:id', requireAuth, (req, res) => {
@@ -417,7 +417,7 @@ router.patch('/:id', requireAuth, (req, res) => {
try {
try {
const emote = db.getEmoteById(req.params.id);
const emote = db.getEmoteById(req.params.id);
if (!emote) return res.status(404).json({ error: 'Emote not found' });
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) {
if (!allowed) {
const ownerId = emote.channel_owner_id || emote.user_id;
const ownerId = emote.channel_owner_id || emote.user_id;
const channel = db.getChannelByUserId(ownerId);
const channel = db.getChannelByUserId(ownerId);
@@ -475,7 +475,7 @@ router.delete('/:id', requireAuth, (req, res) => {
@@ -475,7 +475,7 @@ router.delete('/:id', requireAuth, (req, res) => {
const emote = db.getEmoteById(req.params.id);
const emote = db.getEmoteById(req.params.id);
if (!emote) return res.status(404).json({ error: 'Emote not found' });
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.
// 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) {
if (!allowed) {
const ownerId = emote.channel_owner_id || emote.user_id;
const ownerId = emote.channel_owner_id || emote.user_id;
const channel = db.getChannelByUserId(ownerId);
const channel = db.getChannelByUserId(ownerId);
@@ -12,6 +12,7 @@
@@ -12,6 +12,7 @@
'use strict';
'use strict';
const express = require('express');
const express = require('express');
const db = require('../db/database');
const db = require('../db/database');
+const { can } = require('../auth/permissions');
const media = require('../media-client');
const media = require('../media-client');
const router = express.Router();
const router = express.Router();
@@ -548,7 +549,7 @@ router.get('/discover', optionalAuth, async (req, res) => {
@@ -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).
// Admin: pick a new Star of OpenVibe right now (the job otherwise rotates daily).
router.post('/star/rotate', requireAuth, async (req, res) => {
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 {
try {
const out = await require('./star-job').rotate({ force: true });
const out = await require('./star-job').rotate({ force: true });
_starCache = { at: 0, key: '', data: null };
_starCache = { at: 0, key: '', data: null };
@@ -418,8 +418,8 @@ function banRequestUser(req) {
@@ -418,8 +418,8 @@ function banRequestUser(req) {
return req._ovBanUser;
return req._ovBanUser;
}
}
/** Admins (the site owner) pass IP / network bans — they may live on the same network as a banned person. */
/** 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). */
/** 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) {
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');
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');
@@ -18,6 +18,7 @@
@@ -18,6 +18,7 @@
const express = require('express');
const express = require('express');
const multer = require('multer');
const multer = require('multer');
const db = require('../db/database');
const db = require('../db/database');
+const { can } = require('../auth/permissions');
const media = require('../media-client');
const media = require('../media-client');
const { requireAuth, optionalAuth, requireAdmin } = require('../auth/auth');
const { requireAuth, optionalAuth, requireAdmin } = require('../auth/auth');
@@ -268,7 +269,7 @@ function toCommunity(subPath) {
@@ -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.' });
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;
headers['X-OV-Subject'] = sid;
// Live staff moderate pastes as staff (Community checks our community.paste.moderate grant).
// 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;
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) };
const opts = { method: req.method, headers, redirect: 'manual', signal: AbortSignal.timeout(req.method === 'GET' ? 20000 : 120000) };
@@ -12,6 +12,7 @@ const path = require('path');
@@ -12,6 +12,7 @@ const path = require('path');
const fs = require('fs');
const fs = require('fs');
const multer = require('multer');
const multer = require('multer');
const db = require('../db/database');
const db = require('../db/database');
+const { can } = require('../auth/permissions');
const media = require('../media-client');
const media = require('../media-client');
const access = require('./access');
const access = require('./access');
const liveThumbs = require('./live-thumbs');
const liveThumbs = require('./live-thumbs');
@@ -32,7 +33,7 @@ router.post('/live/:streamId', requireAuth, upload.single('thumbnail'), (req, re
@@ -32,7 +33,7 @@ router.post('/live/:streamId', requireAuth, upload.single('thumbnail'), (req, re
const streamId = parseInt(req.params.streamId);
const streamId = parseInt(req.params.streamId);
const stream = db.getStreamById(streamId);
const stream = db.getStreamById(streamId);
if (!stream) return res.status(404).json({ error: 'Stream not found' });
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' });
return res.status(403).json({ error: 'Not your stream' });
}
}
if (!stream.is_live) return res.status(400).json({ error: 'Stream is not live' });
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) {
@@ -54,7 +55,7 @@ async function generateFor(kind, id, req, res) {
const notFound = () => res.status(404).json({ error: `${kind === 'vod' ? 'VOD' : 'Clip'} not found` });
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).
// 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();
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;
const isPublic = meta.visibility ? meta.visibility === 'public' : !!meta.is_public;
if (!canManage && !isPublic) return res.status(403).json({ error: `Not your ${kind}` });
if (!canManage && !isPublic) return res.status(403).json({ error: `Not your ${kind}` });
const out = await media.generateThumbnail(kind, id);
const out = await media.generateThumbnail(kind, id);
@@ -186,7 +186,7 @@ router.post('/stream/:streamId/chunk', requireAuth, memUpload.single('chunk'), a
@@ -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' });
if (!req.file) return res.status(400).json({ error: 'No chunk data' });
const stream = db.getStreamById(streamId);
const stream = db.getStreamById(streamId);
if (!stream) return res.status(404).json({ error: 'Stream not found' });
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' });
return res.status(403).json({ error: 'Not your stream' });
}
}
const vodPolicy = db.getChannelVodRecordingPolicyByUserId(stream.user_id, stream.managed_stream_id);
const vodPolicy = db.getChannelVodRecordingPolicyByUserId(stream.user_id, stream.managed_stream_id);
@@ -239,7 +239,7 @@ router.post('/stream/:streamId/finalize', requireAuth, async (req, res) => {
@@ -239,7 +239,7 @@ router.post('/stream/:streamId/finalize', requireAuth, async (req, res) => {
const streamId = parseInt(req.params.streamId);
const streamId = parseInt(req.params.streamId);
const stream = db.getStreamById(streamId);
const stream = db.getStreamById(streamId);
if (!stream) return res.status(404).json({ error: 'Stream not found' });
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' });
return res.status(403).json({ error: 'Not your stream' });
}
}
const rec = recorder.getActiveRecording(streamId);
const rec = recorder.getActiveRecording(streamId);
@@ -603,7 +603,7 @@ router.post('/upload', requireAuth, memUpload.single('video'), async (req, res)
@@ -603,7 +603,7 @@ router.post('/upload', requireAuth, memUpload.single('video'), async (req, res)
const { stream_id, title } = req.body;
const { stream_id, title } = req.body;
if (stream_id) {
if (stream_id) {
const stream = db.getStreamById(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' });
return res.status(403).json({ error: 'Not your stream' });
}
}
}
}
@@ -9,7 +9,7 @@
@@ -9,7 +9,7 @@
const express = require('express');
const express = require('express');
const router = express.Router();
const router = express.Router();
const { requireAuth } = require('../auth/auth');
const { requireAuth } = require('../auth/auth');
-const { isOwner, isSensitiveSettingKey } = require('../auth/permissions');
+const { isOwner, isSensitiveSettingKey, can } = require('../auth/permissions');
const newsService = require('./news-service');
const newsService = require('./news-service');
// Source `config` blobs can hold credentials (e.g. NewsAPI apiKey) that live
// Source `config` blobs can hold credentials (e.g. NewsAPI apiKey) that live
@@ -33,7 +33,7 @@ function redactSourceConfig(source) {
@@ -33,7 +33,7 @@ function redactSourceConfig(source) {
// ── Get all news sources + their status (admin) ─────────────
// ── Get all news sources + their status (admin) ─────────────
router.get('/sources', requireAuth, (req, res) => {
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();
let sources = newsService.getSources();
if (!isOwner(req.user)) sources = sources.map(redactSourceConfig);
if (!isOwner(req.user)) sources = sources.map(redactSourceConfig);
res.json({ sources });
res.json({ sources });
@@ -41,7 +41,7 @@ router.get('/sources', requireAuth, (req, res) => {
@@ -41,7 +41,7 @@ router.get('/sources', requireAuth, (req, res) => {
// ── Update a news source config (admin) ─────────────────────
// ── Update a news source config (admin) ─────────────────────
router.put('/sources/:id', requireAuth, (req, res) => {
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 {
try {
const { enabled } = req.body;
const { enabled } = req.body;
let { config } = req.body;
let { config } = req.body;
@@ -8,6 +8,7 @@
@@ -8,6 +8,7 @@
const express = require('express');
const express = require('express');
const router = express.Router();
const router = express.Router();
const db = require('../db/database');
const db = require('../db/database');
+const { can } = require('../auth/permissions');
const recap = require('./recap');
const recap = require('./recap');
const { requireAuth, optionalAuth } = require('../auth/auth');
const { requireAuth, optionalAuth } = require('../auth/auth');
@@ -45,7 +46,7 @@ router.post('/:streamId/regenerate', requireAuth, async (req, res) => {
@@ -45,7 +46,7 @@ router.post('/:streamId/regenerate', requireAuth, async (req, res) => {
const id = parseInt(req.params.streamId, 10);
const id = parseInt(req.params.streamId, 10);
const stream = db.getStreamById(id);
const stream = db.getStreamById(id);
if (!stream) return res.status(404).json({ error: 'Stream not found' });
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);
const r = await recap.buildRecap(id);
if (!r) return res.status(409).json({ error: 'Stream is live or has no data yet' });
if (!r) return res.status(409).json({ error: 'Stream is live or has no data yet' });
res.json({ recap: r });
res.json({ recap: r });
@@ -13,6 +13,7 @@
@@ -13,6 +13,7 @@
const express = require('express');
const express = require('express');
const db = require('../db/database');
const db = require('../db/database');
+const { can } = require('../auth/permissions');
const { optionalAuth } = require('../auth/auth');
const { optionalAuth } = require('../auth/auth');
const router = express.Router();
const router = express.Router();
@@ -159,7 +160,7 @@ router.get('/channel/:username/dashboard', optionalAuth, (req, res) => {
@@ -159,7 +160,7 @@ router.get('/channel/:username/dashboard', optionalAuth, (req, res) => {
if (!channel) return res.status(404).json({ error: 'Channel not found' });
if (!channel) return res.status(404).json({ error: 'Channel not found' });
// Only the channel owner (or admin) can see the dashboard
// 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' });
if (!isOwner) return res.status(403).json({ error: 'Access denied' });
const days = Math.min(parseInt(req.query.days) || 30, 365);
const days = Math.min(parseInt(req.query.days) || 30, 365);
@@ -118,7 +118,7 @@ class CallServer {
@@ -118,7 +118,7 @@ class CallServer {
_canSeePrivate(ch, viewer) {
_canSeePrivate(ch, viewer) {
if (!ch.private) return true;
if (!ch.private) return true;
if (!viewer) return false;
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));
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). */
/** A caller invited this user to their call (POST /voice-channels/call-user). */
@@ -198,7 +198,7 @@ class CallServer {
@@ -198,7 +198,7 @@ class CallServer {
if (!ch || ch.permanent) return false;
if (!ch || ch.permanent) return false;
if (ch.createdBy !== userId) {
if (ch.createdBy !== userId) {
const user = db.getUserById(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.endCall(channelId);
this.channels.delete(channelId);
this.channels.delete(channelId);
@@ -296,7 +296,7 @@ class CallServer {
@@ -296,7 +296,7 @@ class CallServer {
}
}
let fromIp = 0;
let fromIp = 0;
for (const [, info] of room) if (info.ip === ip) fromIp++;
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;
const maxP = channel.maxParticipants || MAX_PARTICIPANTS;
if (room.size >= maxP) { ws.send(JSON.stringify({ type: 'error', message: `Channel full (max ${maxP})` })); ws.close(); return; }
if (room.size >= maxP) { ws.send(JSON.stringify({ type: 'error', message: `Channel full (max ${maxP})` })); ws.close(); return; }
@@ -342,7 +342,7 @@ class CallServer {
@@ -342,7 +342,7 @@ class CallServer {
_canModerate(user, channelId) {
_canModerate(user, channelId) {
if (!user) return false;
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);
const ch = this.channels.get(channelId);
if (ch?.createdBy === user.id) return true;
if (ch?.createdBy === user.id) return true;
if (ch?.streamId) return permissions.canModerateCall(user, ch.streamId);
if (ch?.streamId) return permissions.canModerateCall(user, ch.streamId);
@@ -475,7 +475,7 @@ class CallServer {
@@ -475,7 +475,7 @@ class CallServer {
case 'end-call': case 'end-channel': {
case 'end-call': case 'end-channel': {
const c = room.get(peerId); if (!c) break;
const c = room.get(peerId); if (!c) break;
const ch = this.channels.get(channelId);
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); }
if (canEnd) { this.endCall(channelId); if (ch && !ch.permanent) this.channels.delete(channelId); }
break;
break;
}
}
@@ -8,6 +8,7 @@
@@ -8,6 +8,7 @@
const express = require('express');
const express = require('express');
const db = require('../db/database');
const db = require('../db/database');
+const { can } = require('../auth/permissions');
const { requireAuth } = require('../auth/auth');
const { requireAuth } = require('../auth/auth');
const restreamManager = require('./restream-manager');
const restreamManager = require('./restream-manager');
const chatRelayService = require('../integrations/chat-relay-service');
const chatRelayService = require('../integrations/chat-relay-service');
@@ -495,7 +496,7 @@ router.post('/viewer-counts', requireAuth, (req, res) => {
@@ -495,7 +496,7 @@ router.post('/viewer-counts', requireAuth, (req, res) => {
if (!Number.isFinite(destId) || (count != null && !Number.isFinite(count))) continue;
if (!Number.isFinite(destId) || (count != null && !Number.isFinite(count))) continue;
// Only your own destinations: this feeds external viewer totals and PowerChat view counts.
// Only your own destinations: this feeds external viewer totals and PowerChat view counts.
const dest = db.getRestreamDestinationById(destId);
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);
restreamManager.setViewerCount(destId, count);
}
}
res.json({ ok: true });
res.json({ ok: true });
@@ -23,6 +23,7 @@
@@ -23,6 +23,7 @@
*/
*/
const express = require('express');
const express = require('express');
const db = require('../db/database');
const db = require('../db/database');
+const { can } = require('../auth/permissions');
const config = require('../config');
const config = require('../config');
const { requireAuth, requireStreamer, optionalAuth } = require('../auth/auth');
const { requireAuth, requireStreamer, optionalAuth } = require('../auth/auth');
const jsmpegRelay = require('./jsmpeg-relay');
const jsmpegRelay = require('./jsmpeg-relay');
@@ -94,7 +95,7 @@ const { publicManagedStream, publicChannel, publicStream } = require('../web/ser
@@ -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.
* going live on a slot, heartbeats, call settings and WHIP publishing stay with the streamer alone.
*/
*/
function staffMayModerate(actor, ownerUserId) {
function staffMayModerate(actor, ownerUserId) {
- if (!actor || actor.role !== 'admin') return false;
+ if (!can(actor, 'staff.streams.manage')) return false;
const target = db.getUserById(ownerUserId);
const target = db.getUserById(ownerUserId);
return !(target && target.is_owner);
return !(target && target.is_owner);
}
}
@@ -104,7 +105,7 @@ function ownControlConfigId(req, raw) {
@@ -104,7 +105,7 @@ function ownControlConfigId(req, raw) {
if (raw === null || raw === undefined || raw === '') return null;
if (raw === null || raw === undefined || raw === '') return null;
const id = parseInt(raw);
const id = parseInt(raw);
const cfg = Number.isFinite(id) ? db.getControlConfig(id) : null;
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;
return id;
}
}
@@ -251,7 +252,7 @@ router.get('/channel/:username', optionalAuth, async (req, res) => {
@@ -251,7 +252,7 @@ router.get('/channel/:username', optionalAuth, async (req, res) => {
// Show hidden (private + unlisted) VODs/clips to the channel owner AND admins;
// Show hidden (private + unlisted) VODs/clips to the channel owner AND admins;
// everyone else sees public only.
// everyone else sees public only.
const isOwner = req.user && req.user.id === channel.user_id;
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 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 vodOffset = Math.max(parseInt(req.query.vodOffset || '0', 10), 0);
const ALLOWED_VOD_ORDERS = new Set(['newest', 'oldest', 'views', 'peak_viewers']);
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) => {
@@ -594,7 +595,7 @@ router.get('/channel/:username/clips-taken', optionalAuth, async (req, res) => {
const user = db.getUserByUsername(req.params.username);
const user = db.getUserByUsername(req.params.username);
if (!user) return res.status(404).json({ error: 'Not found' });
if (!user) return res.status(404).json({ error: 'Not found' });
const isOwner = req.user && req.user.id === user.id;
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 ALLOWED_SORT = new Set(['newest', 'oldest', 'views']);
const orderBy = ALLOWED_SORT.has(req.query.sort) ? req.query.sort : 'newest';
const orderBy = ALLOWED_SORT.has(req.query.sort) ? req.query.sort : 'newest';
const sourceStreamerId = parseInt(req.query.of, 10) || null;
const sourceStreamerId = parseInt(req.query.of, 10) || null;
@@ -14,6 +14,7 @@
@@ -14,6 +14,7 @@
const express = require('express');
const express = require('express');
const router = express.Router();
const router = express.Router();
const { requireAuth, optionalAuth } = require('../auth/auth');
const { requireAuth, optionalAuth } = require('../auth/auth');
+const { can } = require('../auth/permissions');
const themeService = require('./theme-service');
const themeService = require('./theme-service');
/* ── List all public themes ────────────────────────────────── */
/* ── List all public themes ────────────────────────────────── */
@@ -146,7 +147,7 @@ router.put('/:id', requireAuth, (req, res) => {
@@ -146,7 +147,7 @@ router.put('/:id', requireAuth, (req, res) => {
/* ── Delete community theme ────────────────────────────────── */
/* ── Delete community theme ────────────────────────────────── */
router.delete('/:id', requireAuth, (req, res) => {
router.delete('/:id', requireAuth, (req, res) => {
try {
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);
themeService.deleteTheme(parseInt(req.params.id), req.user.id, isAdmin);
res.json({ success: true });
res.json({ success: true });
} catch (err) {
} catch (err) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,93 @@
@@ -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);