openvibers/openvibe.live Commits

Projection role rules match Live's roles: 'streamer' is Live's own (a channel), so a Network event never lowers it; staff roles follow an explicit Network role change both ways, and someone who has streamed keeps streamer when Network removes staff; any other profile event only raises the role, like the token sync (40 Live streamers vs 7 on Network would otherwise have been demoted)

OP OpenVibers committed 25 Sept 2026 · 5bb4f8f3d4 · 2 files, +35 −7


Changes

ddiff --git a/server/auth/subject-projection.js b/server/auth/subject-projection.js
iindex e498ffef..b76b1c26 100644
---- a/server/auth/subject-projection.js
++++ b/server/auth/subject-projection.js
@@@ -7,16 +7,32 @@
* colour, role and ban, with Network's profile revision. An event whose revision is
* not newer than the row is ignored (events can arrive out of order or twice).
*
-- * The linked Live account follows the projection in the same step: role both ways (a downgrade too, which
-- * the token sync in auth.js never does, and which /internal/user-role pushed before), picture, colour,
-- * display name and username (server/auth/usernames.js keeps /@old answering). The local owner keeps admin
-- * (is_owner is Live's). A Network ban is recorded here and never touches Live's own users.is_banned: the
++ * The linked Live account follows the projection in the same step: picture, colour, display name and
++ * username (server/auth/usernames.js keeps /@old answering), and the role by these rules:
++ * - an event whose `changed` includes role is Network changing it: staff roles (global_mod, admin) follow it
++ * both ways (a downgrade too, which the token sync in auth.js never does; /internal/user-role pushed it
++ * before), but `streamer` is Live's own (someone with a channel, ensureStreamerRoleOnFeed), so a person
++ * who has streamed keeps it rather than dropping to user;
++ * - any other event only ever raises the role, like the token sync;
++ * - the local owner keeps admin (is_owner is Live's). A Network ban is recorded here and never touches Live's own users.is_banned: the
* person cannot sign in anyway (Network refuses and revokes their tokens), and unbanning on Network must
* not lift a ban Live's staff set. Chat drops what it cached about them.
*/
const db = require('../db/database');
const ROLES = ['user', 'streamer', 'global_mod', 'admin'];
++const RANK = { user: 0, streamer: 1, global_mod: 2, admin: 3 };
++const STAFF_ROLES = new Set(['global_mod', 'admin']);
++
++/** The Live role after a Network event (see the rules above). */
++function nextRole(user, p) {
++ if (user.is_owner) return 'admin';
++ const current = ROLES.includes(user.role) ? user.role : 'user';
++ if (!(Array.isArray(p.changed) && p.changed.includes('role'))) return RANK[p.role] > RANK[current] ? p.role : current;
++ if (STAFF_ROLES.has(p.role)) return p.role;
++ const streamed = !!db.getDb().prepare('SELECT 1 FROM streams WHERE user_id = ? LIMIT 1').get(user.id);
++ return streamed || RANK[p.role] >= RANK.streamer ? 'streamer' : 'user';
++}
const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
let ready = false;
@@@ -69,7 +85,7 @@ function apply(p, { notify = () => {} } = {}) {
display_name: str(p.display_name, 120), avatar_url: str(p.avatar_url, 500), profile_color: str(p.profile_color, 32), role: p.role, banned: p.banned ? 1 : 0 });
const user = localUser(subject, p.network_user_id);
if (!user) return 'updated';
-- const role = user.is_owner ? 'admin' : p.role; // the local owner stays admin (is_owner is Live's)
++ const role = nextRole(user, p);
const next = { role, avatar_url: str(p.avatar_url, 500) || user.avatar_url, profile_color: str(p.profile_color, 32) || user.profile_color, display_name: str(p.display_name, 120) || user.display_name };
const changed = Object.keys(next).filter((k) => next[k] !== user[k]);
if (changed.length) d.prepare(`UPDATE users SET ${changed.map((k) => `${k} = @${k}`).join(', ')} WHERE id = @id`).run({ ...Object.fromEntries(changed.map((k) => [k, next[k]])), id: user.id });
ddiff --git a/test/subject-projection.test.js b/test/subject-projection.test.js
iindex a9511226..a3118e13 100644
---- a/test/subject-projection.test.js
++++ b/test/subject-projection.test.js
@@@ -49,7 +49,7 @@ const envelope = (p) => ({ event_id: ids.newId('event'), event_type: 'network.us
};
const user = (id) => db.getUserById(id);
try {
-- const p1 = payload(ALEX, 7, 3, { profile_color: '#22c55e', avatar_url: 'https://openvibe.media/avatar/alex' });
++ const p1 = payload(ALEX, 7, 3, { profile_color: '#22c55e', avatar_url: 'https://openvibe.media/avatar/alex', changed: ['role', 'profile_color', 'avatar_url'] });
assert.ok(validate('network.user.updated@1', p1).valid, 'the payload matches the contract');
assert.strictEqual(await deliver(p1), 204);
assert.strictEqual(user(1).role, 'user', 'a downgrade applies (the token sync never downgrades)');
@@@ -64,11 +64,23 @@ const envelope = (p) => ({ event_id: ids.newId('event'), event_type: 'network.us
assert.strictEqual(user(1).role, 'streamer', 'an upgrade applies');
assert.strictEqual(user(1).username, 'alex_new', 'the rename follows');
++ // 'streamer' is Live's own: a Live streamer stays one whatever Network's role says.
++ d.prepare("INSERT INTO users (id, username, password_hash, role) VALUES (3, 'cam', '$sso$z', 'streamer')").run();
++ const CAM = ids.newId('user');
++ d.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, service_username, subject_id) VALUES (3, 'network', '30', 'cam', ?)").run(CAM);
++ d.prepare("INSERT INTO streams (user_id, title, is_live) VALUES (3, 'first stream', 0)").run();
++ assert.strictEqual(await deliver(payload(CAM, 30, 1, { username: 'cam', role: 'user', avatar_url: 'https://openvibe.media/avatar/cam', changed: ['avatar_url'] })), 204);
++ assert.strictEqual(user(3).role, 'streamer', 'a profile event never lowers the role');
++ assert.strictEqual(await deliver(payload(CAM, 30, 2, { username: 'cam', role: 'global_mod', changed: ['role'] })), 204);
++ assert.strictEqual(user(3).role, 'global_mod', 'Network makes them staff');
++ assert.strictEqual(await deliver(payload(CAM, 30, 3, { username: 'cam', role: 'user', changed: ['role'] })), 204);
++ assert.strictEqual(user(3).role, 'streamer', 'staff removed by Network: someone who has streamed keeps streamer');
++
assert.strictEqual(await deliver(payload(ALEX, 7, 5, { username: 'alex_new', role: 'streamer', banned: true, changed: ['banned'] })), 204);
assert.strictEqual(projection.get(ALEX).banned, 1);
assert.ok(!user(1).is_banned, "a Network ban never sets Live's own ban");
-- assert.strictEqual(await deliver(payload(OWNER, 1, 1, { username: 'boss', role: 'user' })), 204);
++ assert.strictEqual(await deliver(payload(OWNER, 1, 1, { username: 'boss', role: 'user', changed: ['role'] })), 204);
assert.strictEqual(user(2).role, 'admin', 'the local owner keeps admin');
assert.strictEqual(await deliver(payload(GHOST, 99, 1, { username: 'ghost' })), 204);