openvibers/openvibe.network Commits

Network issues staff capabilities in user tokens (D05, ADR-022): staff members' access tokens (login and OAuth) carry staff_caps from the contracts staff map, and the owner carries is_owner; people who are not staff carry neither, so their tokens stay small. Services' staff.can() prefers issued claims, so a map change reaches every service with the next token. test/staff-claims.test.js

OP OpenVibers committed 24 Sept 2026 · dde337433d · 4 files, +46 −0


Changes

ddiff --git a/server/auth/oauth-routes.js b/server/auth/oauth-routes.js
iindex 0ebfb897..e43ef9db 100644
---- a/server/auth/oauth-routes.js
++++ b/server/auth/oauth-routes.js
@@@ -9,6 +9,7 @@ const express = require('express');
const crypto = require('crypto');
const jwt = require('jsonwebtoken');
const subjects = require('../identity/subjects');
++const { staffClaims } = require('./staff-claims');
const principals = require('../identity/principals');
const devTokens = require('../developer/tokens');
const oidc = require('./oidc');
@@@ -397,6 +398,7 @@ function issueTokenPair(db, config, req, user, client, family = {}) {
role: user.role,
avatar_url: user.avatar_url,
profile_color: user.profile_color,
++ ...staffClaims(user),
},
privateKey,
{
ddiff --git a/server/auth/routes.js b/server/auth/routes.js
iindex d41e5b8e..cd57f7be 100644
---- a/server/auth/routes.js
++++ b/server/auth/routes.js
@@@ -11,6 +11,7 @@ const crypto = require('crypto');
const jwt = require('jsonwebtoken');
const { v4: uuidv4 } = require('uuid');
const subjects = require('../identity/subjects');
++const { staffClaims } = require('./staff-claims');
const router = express.Router();
@@@ -62,6 +63,7 @@ function signToken(user, privateKey, config) {
role: user.role,
avatar_url: user.avatar_url,
profile_color: user.profile_color,
++ ...staffClaims(user),
},
privateKey,
{
ddiff --git a/server/auth/staff-claims.js b/server/auth/staff-claims.js
nnew file mode 100644
iindex 00000000..08bb15e9
---- /dev/null
++++ b/server/auth/staff-claims.js
@@@ -0,0 +1,20 @@
++'use strict';
++/**
++ * Staff claims in user tokens (ADR-022, openvibe-contracts manifests/policy/staff-roles.json): Network owns
++ * the roles and issues each staff member's capabilities as `staff_caps`, and the owner as `is_owner: true`.
++ * Services ask `staff.can(claims, 'staff.<area>.<action>')`; issued claims win over the role, so a change
++ * to the map reaches every service with the next token. People who are not staff carry neither claim
++ * (the map gives them nothing either way), which keeps their tokens small.
++ */
++const { staff } = require('openvibe-contracts');
++const { isOwner } = require('./owner-guard');
++
++function staffClaims(user) {
++ if (!user) return {};
++ const owner = user.role === 'admin' && isOwner(user);
++ const caps = staff.capabilitiesOf({ role: user.role, is_owner: owner });
++ if (!caps.length) return {};
++ return { ...(owner ? { is_owner: true } : {}), staff_caps: caps };
++}
++
++module.exports = { staffClaims };
ddiff --git a/test/staff-claims.test.js b/test/staff-claims.test.js
nnew file mode 100644
iindex 00000000..5f31e4df
---- /dev/null
++++ b/test/staff-claims.test.js
@@@ -0,0 +1,22 @@
++'use strict';
++/** Network issues each staff member's capabilities (staff_caps) and the owner flag in user tokens (ADR-022). */
++const assert = require('assert');
++process.env.OWNER_USERNAME = 'theowner';
++const { staff } = require('openvibe-contracts');
++const { staffClaims } = require('../server/auth/staff-claims');
++
++assert.deepStrictEqual(staffClaims({ role: 'user', username: 'a' }), {}, 'not staff: no claims, small token');
++assert.deepStrictEqual(staffClaims({ role: 'streamer', username: 'b' }), {});
++assert.deepStrictEqual(staffClaims({ role: 'global_mod', username: 'm' }), { staff_caps: staff.capabilitiesOf('global_mod') });
++assert.deepStrictEqual(staffClaims({ role: 'admin', username: 'boss' }), { staff_caps: staff.capabilitiesOf('admin') });
++const owner = staffClaims({ role: 'admin', username: 'TheOwner' });
++assert.strictEqual(owner.is_owner, true);
++assert.deepStrictEqual(owner.staff_caps, staff.capabilitiesOf('owner'));
++assert.deepStrictEqual(staffClaims({ role: 'user', username: 'theowner' }), {}, 'the owner name without the admin role is nobody');
++// Services read the issued claims exactly as the map answers.
++assert.strictEqual(staff.can(owner, 'staff.secrets.manage'), true);
++assert.strictEqual(staff.can({ role: 'admin', ...staffClaims({ role: 'admin', username: 'boss' }) }, 'staff.secrets.manage'), false);
++// Both user-token signers add them.
++const fs = require('fs');
++for (const f of ['server/auth/routes.js', 'server/auth/oauth-routes.js']) assert.ok(fs.readFileSync(require('path').join(__dirname, '..', f), 'utf8').includes('...staffClaims(user)'), f);
++console.log('staff claims: all checks passed');