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)
Changes
@@@ -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 });@@@ -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.usassert.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);
@@ -7,16 +7,32 @@
@@ -7,16 +7,32 @@
* colour, role and ban, with Network's profile revision. An event whose revision is
* 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).
* 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
* 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.
* not lift a ban Live's staff set. Chat drops what it cached about them.
*/
*/
const db = require('../db/database');
const db = require('../db/database');
const ROLES = ['user', 'streamer', 'global_mod', 'admin'];
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}$/;
const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
let ready = false;
let ready = false;
@@ -69,7 +85,7 @@ function apply(p, { notify = () => {} } = {}) {
@@ -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 });
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);
const user = localUser(subject, p.network_user_id);
if (!user) return 'updated';
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 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]);
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 });
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 });
@@ -49,7 +49,7 @@ const envelope = (p) => ({ event_id: ids.newId('event'), event_type: 'network.us
@@ -49,7 +49,7 @@ const envelope = (p) => ({ event_id: ids.newId('event'), event_type: 'network.us
};
};
const user = (id) => db.getUserById(id);
const user = (id) => db.getUserById(id);
try {
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.ok(validate('network.user.updated@1', p1).valid, 'the payload matches the contract');
assert.strictEqual(await deliver(p1), 204);
assert.strictEqual(await deliver(p1), 204);
assert.strictEqual(user(1).role, 'user', 'a downgrade applies (the token sync never downgrades)');
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
@@ -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).role, 'streamer', 'an upgrade applies');
assert.strictEqual(user(1).username, 'alex_new', 'the rename follows');
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(await deliver(payload(ALEX, 7, 5, { username: 'alex_new', role: 'streamer', banned: true, changed: ['banned'] })), 204);
assert.strictEqual(projection.get(ALEX).banned, 1);
assert.strictEqual(projection.get(ALEX).banned, 1);
assert.ok(!user(1).is_banned, "a Network ban never sets Live's own ban");
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(user(2).role, 'admin', 'the local owner keeps admin');
assert.strictEqual(await deliver(payload(GHOST, 99, 1, { username: 'ghost' })), 204);
assert.strictEqual(await deliver(payload(GHOST, 99, 1, { username: 'ghost' })), 204);