openvibers/openvibe.live Commits
Live follows the OpenVibe account (WS-B task 2, Contracts 0.43.0): subject_projection kept from Network's network.user.updated (POST /internal/network-events; the subscribe script's --network asks for it), and the linked account follows it: role both ways (a downgrade too), picture, colour, display name and username; an older revision changes nothing, the local owner stays admin, a Network ban never touches Live's own. Live keeps no passwords or emails: createUser refuses a real password hash and never stores an address, the profile route ignores email, the bootstrap admin gets the SSO placeholder instead of a bcrypt hash, the settings page links to the account for email and password; test/identity-columns.test.js is the tripwire
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.42.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.43.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.9.1","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0","sanitize-html": "^2.17.7",@@@ -2814,9 +2814,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.42.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.42.0",-- "integrity": "sha512-lQUPg3bkpusZmFRyJHaARwnA4J+WcmQfVdM28yOHYhVChiMs/3rr3hl10JucKOlxxx9Cii1Go0iv+IfxqVO8UA==",++ "version": "0.43.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.43.0",++ "integrity": "sha512-zgyfoqWUzSr8lCEqY6q+XVliBldOUWSCacliBsBXRz12fK2d6ELrmHOUbZA6evobT/MnNpxs6rDvcmaVfb+nDQ==","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.42.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.43.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.9.1","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0","sanitize-html": "^2.17.7",@@@ -480,7 +480,7 @@<div class="settings-row"><span class="settings-row-label">Email</span><div class="settings-row-content">-- <input type="email" id="set-email" placeholder="[email protected]">++ <span class="settings-hint">Your email and password are kept by your OpenVibe account: <a href="https://openvibe.network/my#security" target="_blank" rel="noopener">change them there</a>.</span></div></div><div class="settings-row">@@@ -185,7 +185,7 @@ openvibe.network /api/admin/streamer-mod/* → OpenVibe.Live /api/mod/*</code><tr><td>POST</td><td>/api/auth/logout</td><td>No</td><td>Clear auth cookies</td></tr><tr><td>GET</td><td>/api/auth/sso/status</td><td>No</td><td>SSO enabled flag + login URL</td></tr><tr><td>GET</td><td>/api/auth/me</td><td>Yes</td><td>Current user + capabilities</td></tr>--<tr><td>PUT</td><td>/api/auth/profile</td><td>Yes</td><td>Update display_name (recase only), bio, email, profile_color</td></tr>++<tr><td>PUT</td><td>/api/auth/profile</td><td>Yes</td><td>Update display_name (recase only), bio, profile_color (email and password are the OpenVibe account's: openvibe.network/my)</td></tr><tr><td>GET</td><td>/api/auth/user/:username</td><td>No</td><td>Public profile</td></tr><tr><td>POST</td><td>/api/auth/avatar</td><td>Yes</td><td>Upload avatar (multipart <code>avatar</code>, ≤20MB)</td></tr><tr><td>GET / POST</td><td>/api/auth/stream-key[/regenerate]</td><td>Yes</td><td>Get / regenerate personal stream key</td></tr>@@@ -89,7 +89,6 @@ async function loadSettingsProfile() {const _sv = (id, v) => { const el = document.getElementById(id); if (el) el.value = v; };_sv('set-username', u.username || '');_sv('set-display-name', u.display_name || '');-- _sv('set-email', u.email || '');_sv('set-bio', u.bio || '');_sv('set-profile-color', u.profile_color || '#8b5cf6'); // removed from UI; harmless if absent} catch (e) {@@@ -173,7 +172,6 @@ async function saveSettingsProfile() {}const data = {display_name: dname,-- email: document.getElementById('set-email').value.trim() || null,bio: document.getElementById('set-bio').value.trim(),};// Profile color is managed centrally at openvibe.network now (its SSO sync overrides any@@@ -13,7 +13,7 @@* sudo node /opt/openvibe.live/current/scripts/subscribe-media-events.js --disable # rollback* sudo node /opt/openvibe.live/current/scripts/subscribe-media-events.js --enable # undo a --disable* sudo node /opt/openvibe.live/current/scripts/subscribe-media-events.js --network # Network's-- * network.user.token_valid_after → /internal/network-events (server/auth/network-events.js;++ * network.user.token_valid_after and network.user.updated → /internal/network-events (server/auth/network-events.js;* signed with LIVE_EVENTS_SECRET when set, else MEDIA_EVENTS_SECRET); combines with the flags above** [--live-env /etc/openvibe/live.env] [--endpoint http://127.0.0.1:3000/internal/media-events]@@@ -31,7 +31,7 @@ const fs = require('fs');const TOPICS = ['media.vod.*', 'media.clip.*', 'media.storage.*'];const DEFAULT_ENDPOINT = 'http://127.0.0.1:3000/internal/media-events';--const NETWORK_TOPICS = ['network.user.token_valid_after'];++const NETWORK_TOPICS = ['network.user.token_valid_after', 'network.user.updated'];const NETWORK_ENDPOINT = 'http://127.0.0.1:3000/internal/network-events';const DEFAULT_LIVE_ENV = '/etc/openvibe/live.env';@@@ -2,28 +2,33 @@/*** OpenVibe.Events → Live: identity events from OpenVibe.Network (roadmap WS-B task 4).*-- * POST /internal/network-events the endpoint of Live's subscription to-- * network.user.token_valid_after (scripts/subscribe-media-events.js-- * --network), signed with LIVE_EVENTS_SECRET, else MEDIA_EVENTS_SECRET-- * (Live's one events secret), signature v2 only.++ * POST /internal/network-events the endpoint of Live's subscriptions to++ * network.user.token_valid_after and network.user.updated++ * (scripts/subscribe-media-events.js --network), signed with LIVE_EVENTS_SECRET,++ * else MEDIA_EVENTS_SECRET (Live's one events secret), signature v2 only.*-- * Applying is idempotent (a cutoff only ever moves forward), so a redelivery is a no-op without an-- * inbox. Anything signed but not ours to act on is acknowledged (204) so it is not redelivered.++ * token_valid_after the person's older tokens are refused (./revocations.js)++ * updated the person's profile, role and ban (./subject-projection.js, WS-B task 2)++ *++ * Applying is idempotent (a cutoff only ever moves forward; a profile revision only up), so a redelivery++ * is a no-op without an inbox. Anything signed but not ours to act on is acknowledged (204).*/const revocations = require('./revocations');const EVENT_ID_RE = /^evt_[0-9A-HJKMNP-TV-Z]{26}$/;const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;--const stats = { received: 0, revoked: 0, unchanged: 0, ignored: 0, refused: 0 };++const stats = { received: 0, revoked: 0, unchanged: 0, profiles: 0, ignored: 0, refused: 0 };++const notifyChat = (userId) => { try { const cs = require('../chat/chat-server'); if (cs.remote) cs.userChanged(userId); } catch { /* non-critical */ } };function secret() { return process.env.LIVE_EVENTS_SECRET || process.env.MEDIA_EVENTS_SECRET || ''; }--/** Apply one envelope; returns 'revoked' | 'unchanged' | 'ignored:<why>'. */++/** Apply one envelope; returns 'revoked' | 'unchanged' | 'updated' | 'stale' | 'ignored:<why>'. */function apply(ev) {if (!ev || !EVENT_ID_RE.test(String(ev.event_id || ''))) return 'ignored:envelope';-- if (ev.event_type !== 'network.user.token_valid_after') return 'ignored:type';++ if (ev.event_type !== 'network.user.token_valid_after' && ev.event_type !== 'network.user.updated') return 'ignored:type';if (ev.source !== 'network') return 'ignored:source';const p = ev.payload && typeof ev.payload === 'object' ? ev.payload : {};++ if (ev.event_type === 'network.user.updated') return require('./subject-projection').apply(p, { notify: notifyChat });const subject = p.subject && p.subject.id;const ms = Date.parse(p.valid_after);if (!SUBJECT_RE.test(String(subject || '')) || !Number.isFinite(ms)) return 'ignored:payload';@@@ -39,7 +44,7 @@ function handler(req, res) {if (!delivery) { stats.refused++; return res.status(401).json({ error: 'bad signature' }); }stats.received++;const out = apply(delivery.event);-- if (out === 'revoked') stats.revoked++; else if (out === 'unchanged') stats.unchanged++; else stats.ignored++;++ if (out === 'revoked') stats.revoked++; else if (out === 'unchanged' || out === 'stale') stats.unchanged++; else if (out === 'updated') stats.profiles++; else stats.ignored++;res.status(204).end();}@@@ -50,11 +50,6 @@ function sanitizeDisplayName(raw) {return s;}--function isValidEmail(value) {-- if (!value) return true;-- return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);--}--function isAllowedAvatarUrl(value) {if (!value) return true;if (value.startsWith('/data/avatars/')) return true;@@@ -112,7 +107,6 @@ router.put('/profile', requireAuth, (req, res) => {let display_name = cleanOptionalString(req.body.display_name);let bio = cleanOptionalString(req.body.bio);const avatar_url = cleanOptionalString(req.body.avatar_url);-- const email = cleanOptionalString(req.body.email);const profile_color = cleanOptionalString(req.body.profile_color);const updates = [];const params = [];@@@ -133,9 +127,6 @@ router.put('/profile', requireAuth, (req, res) => {if (bio !== undefined && bio.length > 500) {return res.status(400).json({ error: 'Bio must be 500 characters or fewer' });}-- if (email !== undefined && (!isValidEmail(email) || email.length > 254)) {-- return res.status(400).json({ error: 'Invalid email address' });-- }if (profile_color !== undefined && profile_color !== '' && !/^#[0-9a-fA-F]{6}$/.test(profile_color)) {return res.status(400).json({ error: 'Profile color must be a 6-digit hex color' });}@@@ -143,7 +134,7 @@ router.put('/profile', requireAuth, (req, res) => {if (bio !== undefined) { updates.push('bio = ?'); params.push(bio); }// avatar_url is intentionally NOT settable here — avatars change only via the// /api/auth/avatar image upload (from /settings), which validates the file.-- if (email !== undefined) { updates.push('email = ?'); params.push(email); }++ // email is the OpenVibe account's (openvibe.network/my): Live keeps no copy and ignores one sent here (WS-B task 2).if (profile_color !== undefined) { updates.push('profile_color = ?'); params.push(profile_color); }if (updates.length === 0) {@@@ -383,7 +374,7 @@ function establishNetworkSession(req, res, tokenData) {const stream_key = uuidv4().replace(/-/g, '');const result = db.createUser({username: ssoUser.username,-- email: ssoUser.email || null,++ email: null, // the OpenVibe account keeps it (WS-B task 2)password_hash: '$sso$' + require('crypto').randomBytes(32).toString('hex'), // placeholder, can't login with passworddisplay_name: ssoUser.display_name || ssoUser.username,stream_key,nnew file mode 100644@@@ -0,0 +1,91 @@++'use strict';++/**++ * What Live knows about a person, kept from OpenVibe.Network's network.user.updated (Contracts 0.43.0,++ * roadmap WS-B task 2) instead of Live's own copies.++ *++ * subject_projection one row per Network subject: the person's current username, display name, picture,++ * 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++ * 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 SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++++let ready = false;++function ensureSchema() {++ if (ready) return;++ db.getDb().exec(`CREATE TABLE IF NOT EXISTS subject_projection (++ subject_id TEXT PRIMARY KEY,++ network_user_id INTEGER,++ revision INTEGER NOT NULL,++ username TEXT NOT NULL,++ display_name TEXT,++ avatar_url TEXT,++ profile_color TEXT,++ role TEXT NOT NULL,++ banned INTEGER NOT NULL DEFAULT 0,++ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP++ );++ CREATE INDEX IF NOT EXISTS idx_subject_projection_network ON subject_projection(network_user_id);`);++ ready = true;++}++++/** The local account linked to this Network person, or null. */++function localUser(subject, networkUserId) {++ const d = db.getDb();++ const row = d.prepare("SELECT user_id FROM linked_accounts WHERE service = 'network' AND (subject_id = ? OR service_user_id = ?) ORDER BY subject_id = ? DESC LIMIT 1")++ .get(subject, String(networkUserId), subject);++ return row ? db.getUserById(row.user_id) : null;++}++++/**++ * Apply one network.user.updated payload. → 'updated' | 'stale' | 'ignored:payload'++ * `notify(userId)` is told when the linked account changed (Chat's cache).++ */++function apply(p, { notify = () => {} } = {}) {++ ensureSchema();++ const subject = p && p.subject && p.subject.id;++ if (!SUBJECT_RE.test(String(subject || '')) || !Number.isInteger(p.revision) || p.revision < 1 || typeof p.username !== 'string' || !p.username++ || !ROLES.includes(p.role) || typeof p.banned !== 'boolean') return 'ignored:payload';++ const d = db.getDb();++ return d.transaction(() => {++ const cur = d.prepare('SELECT revision FROM subject_projection WHERE subject_id = ?').get(subject);++ if (cur && cur.revision >= p.revision) return 'stale';++ const str = (v, max) => (typeof v === 'string' && v ? v.slice(0, max) : null);++ d.prepare(`INSERT INTO subject_projection (subject_id, network_user_id, revision, username, display_name, avatar_url, profile_color, role, banned, updated_at)++ VALUES (@subject, @nid, @rev, @username, @display_name, @avatar_url, @profile_color, @role, @banned, CURRENT_TIMESTAMP)++ ON CONFLICT(subject_id) DO UPDATE SET network_user_id = excluded.network_user_id, revision = excluded.revision, username = excluded.username,++ display_name = excluded.display_name, avatar_url = excluded.avatar_url, profile_color = excluded.profile_color, role = excluded.role,++ banned = excluded.banned, updated_at = excluded.updated_at`)++ .run({ subject, nid: Number.isInteger(p.network_user_id) ? p.network_user_id : null, rev: p.revision, username: p.username.slice(0, 64),++ 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 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 });++ let renamed = false;++ if (p.username.toLowerCase() !== String(user.username || '').toLowerCase()) {++ try { renamed = !!require('./usernames').syncUsername(user.id, p.username); } catch { /* the name is taken here: keep the old one */ }++ }++ if (changed.length || renamed) notify(user.id);++ return 'updated';++ })();++}++++/** The projected person for a subject, or null. */++function get(subject) {++ ensureSchema();++ return db.getDb().prepare('SELECT * FROM subject_projection WHERE subject_id = ?').get(subject) || null;++}++++module.exports = { apply, get, ensureSchema, _reset: () => { ready = false; } };@@@ -2765,6 +2765,10 @@ function getUserByStreamKey(key) {}function createUser({ username, email, password_hash, display_name, stream_key }) {++ // Identity is the OpenVibe account's (WS-B task 2): Live stores no password and no email. A real password++ // hash here is a bug, refused before it reaches the table; an email address is never stored.++ if (/^\$(2[abxy]?|argon2|scrypt|pbkdf2)/.test(String(password_hash || ''))) throw new Error('Live stores no passwords: accounts sign in through openvibe.network');++ email = null;return run(`INSERT INTO users (username, email, password_hash, display_name, stream_key)VALUES (?, ?, ?, ?, ?)`,@@@ -1145,19 +1145,19 @@ async function start() {// 2. Create admin from .env config if none exists (first-time setup only)const adminExists = db.get("SELECT id FROM users WHERE role = 'admin' LIMIT 1");if (!adminExists) {-- const bcrypt = require('bcryptjs');const { v4: uuidv4 } = require('uuid');const adminUser = config.adminUsername || 'admin';-- const adminPass = config.adminPassword || 'changeme123';db.createUser({username: adminUser,email: null,-- password_hash: bcrypt.hashSync(adminPass, 10),++ // Live keeps no passwords (sign-in is the OpenVibe account's): this account signs in through SSO once++ // an openvibe.network account with its username exists; ADMIN_PASSWORD is not used.++ password_hash: '$sso$' + require('crypto').randomBytes(32).toString('hex'),display_name: adminUser,stream_key: uuidv4().replace(/-/g, ''),});db.run("UPDATE users SET role = 'admin' WHERE username = ?", [adminUser]);-- console.log(`[Server] Admin user "${adminUser}" created from ADMIN_USERNAME — change password after first login!`);++ console.log(`[Server] Admin user "${adminUser}" created from ADMIN_USERNAME; sign in with the openvibe.network account of that name`);}// Game & Canvas migrated to openvibe.games — no local init needednnew file mode 100644@@@ -0,0 +1,35 @@++'use strict';++// WS-B task 2: Live keeps no passwords and no email addresses (the OpenVibe account keeps them). The code++// never hashes a password or writes users.email, and createUser refuses a real hash or an address.++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-live-idcols-'));++process.env.DB_PATH = path.join(tmp, 'live.db');++process.env.NODE_ENV = 'test';++const quiet = console.log; console.log = () => {}; console.warn = () => {};++const db = require('../server/db/database');++db.initDb();++console.log = quiet;++++const files = [];++const walk = (dir) => { for (const e of fs.readdirSync(dir, { withFileTypes: true })) { const p = path.join(dir, e.name); if (e.isDirectory()) { if (e.name !== 'node_modules') walk(p); } else if (p.endsWith('.js')) files.push(p); } };++walk(path.join(__dirname, '..', 'server'));++const offenders = [];++for (const f of files) {++ const src = fs.readFileSync(f, 'utf8');++ // server/controls hashes robot and camera control secrets (devices, not accounts): allowed.++ if (!f.includes(`${path.sep}controls${path.sep}`) && /require\(['"]bcrypt(js)?['"]\)/.test(src)) offenders.push(`${path.relative(process.cwd(), f)}: requires bcrypt`);++ if (/UPDATE\s+users\s+SET[^;`'"]*\b(email|password_hash)\s*=/i.test(src)) offenders.push(`${path.relative(process.cwd(), f)}: writes users.email or users.password_hash`);++}++assert.deepStrictEqual(offenders, [], 'Live never hashes passwords or writes users.email');++++const base = { username: 'x1', display_name: 'X', stream_key: 'k1' };++assert.throws(() => db.createUser({ ...base, password_hash: '$2a$10$abcdefghijklmnopqrstuv' }), /stores no passwords/);++const id = db.createUser({ ...base, username: 'x2', stream_key: 'k2', email: '[email protected]', password_hash: '$sso$x' }).lastInsertRowid;++assert.ok(id, 'an SSO account is created');++assert.strictEqual(db.getUserById(id).email, null, 'an email address given to createUser is not stored');++fs.rmSync(tmp, { recursive: true, force: true });++console.log('identity columns: all checks passed');nnew file mode 100644@@@ -0,0 +1,85 @@++'use strict';++// Live's subject_projection from Network's network.user.updated (WS-B task 2, Contracts 0.43.0): signed++// deliveries through POST /internal/network-events; the linked account follows the role both ways (a++// downgrade too), picture, colour, display name and username (with /@old kept); an older revision changes++// nothing; the local owner keeps admin; a Network ban never touches Live's own ban; someone with no Live++// account is projected only; a bad payload is ignored; the subscribe script asks for the topic.++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-live-subjproj-'));++process.env.DB_PATH = path.join(tmp, 'live.db');++process.env.NODE_ENV = 'test';++process.env.LIVE_EVENTS_SECRET = 's'.repeat(40);++const quiet = console.log;++console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };++console.warn = () => {};++++const express = require('express');++const { ids, validate } = require('openvibe-contracts');++const { signDeliveryHeaders } = require('openvibe-sdk/events');++const db = require('../server/db/database');++db.initDb();++const networkEvents = require('../server/auth/network-events');++const projection = require('../server/auth/subject-projection');++const { NETWORK_TOPICS } = require('../scripts/subscribe-media-events');++++const ALEX = ids.newId('user'), OWNER = ids.newId('user'), GHOST = ids.newId('user');++const d = db.getDb();++d.prepare("INSERT INTO users (id, username, email, password_hash, role, display_name) VALUES (1, 'alex', NULL, '$sso$x', 'admin', 'Alex'), (2, 'boss', NULL, '$sso$y', 'admin', 'Boss')").run();++d.prepare('UPDATE users SET is_owner = 1 WHERE id = 2').run();++d.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, service_username, subject_id) VALUES (1, 'network', '7', 'alex', ?), (2, 'network', '1', 'boss', ?)").run(ALEX, OWNER);++const payload = (subject, nid, revision, over = {}) => ({ subject: { type: 'user', id: subject }, network_user_id: nid, revision, username: 'alex', display_name: 'Alex', avatar_url: null, profile_color: null, role: 'user', banned: false, changed: ['role'], ...over });++const envelope = (p) => ({ event_id: ids.newId('event'), event_type: 'network.user.updated', version: 1, source: 'network', actor: { type: 'service', id: 'network' },++ timestamp: new Date().toISOString(), visibility: 'internal', subject: { type: 'user', id: p.subject.id }, payload: p });++++(async () => {++ const app = express();++ app.use(express.json({ verify: (req, res, buf) => { req.rawBody = buf; } }));++ app.post('/internal/network-events', networkEvents.handler);++ const server = http.createServer(app);++ await new Promise((r) => server.listen(0, '127.0.0.1', r));++ const url = `http://127.0.0.1:${server.address().port}/internal/network-events`;++ const deliver = (p) => {++ const body = JSON.stringify({ event: envelope(p), seq: 1 });++ return fetch(url, { method: 'POST', headers: { 'content-type': 'application/json', ...signDeliveryHeaders(body, process.env.LIVE_EVENTS_SECRET) }, body }).then((r) => r.status);++ };++ const user = (id) => db.getUserById(id);++ try {++ const p1 = payload(ALEX, 7, 3, { profile_color: '#22c55e', avatar_url: 'https://openvibe.media/avatar/alex' });++ 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)');++ assert.strictEqual(user(1).profile_color, '#22c55e');++ assert.strictEqual(projection.get(ALEX).revision, 3);++++ assert.strictEqual(await deliver(payload(ALEX, 7, 2, { role: 'admin' })), 204);++ assert.strictEqual(user(1).role, 'user', 'an older revision changes nothing');++ assert.strictEqual(networkEvents.stats.unchanged, 1);++++ assert.strictEqual(await deliver(payload(ALEX, 7, 4, { role: 'streamer', username: 'alex_new', changed: ['username', 'role'] })), 204);++ assert.strictEqual(user(1).role, 'streamer', 'an upgrade applies');++ assert.strictEqual(user(1).username, 'alex_new', 'the rename follows');++++ 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(user(2).role, 'admin', 'the local owner keeps admin');++++ assert.strictEqual(await deliver(payload(GHOST, 99, 1, { username: 'ghost' })), 204);++ assert.strictEqual(projection.get(GHOST).username, 'ghost', 'someone with no Live account is projected only');++++ assert.strictEqual(await deliver(payload(ALEX, 7, 9, { role: 'owner' })), 204);++ assert.strictEqual(user(1).role, 'streamer', 'a bad payload is ignored');++ assert.ok(NETWORK_TOPICS.includes('network.user.updated'), 'the subscribe script asks for the topic');++ } finally {++ server.close();++ fs.rmSync(tmp, { recursive: true, force: true });++ }++ quiet('subject projection: all checks passed');++})().catch((err) => { console.error(err); process.exit(1); });
@@ -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.42.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.43.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.9.1",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.9.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.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.42.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.42.0",
- "integrity": "sha512-lQUPg3bkpusZmFRyJHaARwnA4J+WcmQfVdM28yOHYhVChiMs/3rr3hl10JucKOlxxx9Cii1Go0iv+IfxqVO8UA==",
+ "version": "0.43.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.43.0",
+ "integrity": "sha512-zgyfoqWUzSr8lCEqY6q+XVliBldOUWSCacliBsBXRz12fK2d6ELrmHOUbZA6evobT/MnNpxs6rDvcmaVfb+nDQ==",
"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.42.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.43.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.9.1",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.9.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0",
"sanitize-html": "^2.17.7",
"sanitize-html": "^2.17.7",
@@ -480,7 +480,7 @@
@@ -480,7 +480,7 @@
<div class="settings-row">
<div class="settings-row">
<span class="settings-row-label">Email</span>
<span class="settings-row-label">Email</span>
<div class="settings-row-content">
<div class="settings-row-content">
- <input type="email" id="set-email" placeholder="[email protected]">
+ <span class="settings-hint">Your email and password are kept by your OpenVibe account: <a href="https://openvibe.network/my#security" target="_blank" rel="noopener">change them there</a>.</span>
</div>
</div>
</div>
</div>
<div class="settings-row">
<div class="settings-row">
@@ -185,7 +185,7 @@ openvibe.network /api/admin/streamer-mod/* → OpenVibe.Live /api/mod/*</code>
@@ -185,7 +185,7 @@ openvibe.network /api/admin/streamer-mod/* → OpenVibe.Live /api/mod/*</code>
<tr><td>POST</td><td>/api/auth/logout</td><td>No</td><td>Clear auth cookies</td></tr>
<tr><td>POST</td><td>/api/auth/logout</td><td>No</td><td>Clear auth cookies</td></tr>
<tr><td>GET</td><td>/api/auth/sso/status</td><td>No</td><td>SSO enabled flag + login URL</td></tr>
<tr><td>GET</td><td>/api/auth/sso/status</td><td>No</td><td>SSO enabled flag + login URL</td></tr>
<tr><td>GET</td><td>/api/auth/me</td><td>Yes</td><td>Current user + capabilities</td></tr>
<tr><td>GET</td><td>/api/auth/me</td><td>Yes</td><td>Current user + capabilities</td></tr>
-<tr><td>PUT</td><td>/api/auth/profile</td><td>Yes</td><td>Update display_name (recase only), bio, email, profile_color</td></tr>
+<tr><td>PUT</td><td>/api/auth/profile</td><td>Yes</td><td>Update display_name (recase only), bio, profile_color (email and password are the OpenVibe account's: openvibe.network/my)</td></tr>
<tr><td>GET</td><td>/api/auth/user/:username</td><td>No</td><td>Public profile</td></tr>
<tr><td>GET</td><td>/api/auth/user/:username</td><td>No</td><td>Public profile</td></tr>
<tr><td>POST</td><td>/api/auth/avatar</td><td>Yes</td><td>Upload avatar (multipart <code>avatar</code>, ≤20MB)</td></tr>
<tr><td>POST</td><td>/api/auth/avatar</td><td>Yes</td><td>Upload avatar (multipart <code>avatar</code>, ≤20MB)</td></tr>
<tr><td>GET / POST</td><td>/api/auth/stream-key[/regenerate]</td><td>Yes</td><td>Get / regenerate personal stream key</td></tr>
<tr><td>GET / POST</td><td>/api/auth/stream-key[/regenerate]</td><td>Yes</td><td>Get / regenerate personal stream key</td></tr>
@@ -89,7 +89,6 @@ async function loadSettingsProfile() {
@@ -89,7 +89,6 @@ async function loadSettingsProfile() {
const _sv = (id, v) => { const el = document.getElementById(id); if (el) el.value = v; };
const _sv = (id, v) => { const el = document.getElementById(id); if (el) el.value = v; };
_sv('set-username', u.username || '');
_sv('set-username', u.username || '');
_sv('set-display-name', u.display_name || '');
_sv('set-display-name', u.display_name || '');
- _sv('set-email', u.email || '');
_sv('set-bio', u.bio || '');
_sv('set-bio', u.bio || '');
_sv('set-profile-color', u.profile_color || '#8b5cf6'); // removed from UI; harmless if absent
_sv('set-profile-color', u.profile_color || '#8b5cf6'); // removed from UI; harmless if absent
} catch (e) {
} catch (e) {
@@ -173,7 +172,6 @@ async function saveSettingsProfile() {
@@ -173,7 +172,6 @@ async function saveSettingsProfile() {
}
}
const data = {
const data = {
display_name: dname,
display_name: dname,
- email: document.getElementById('set-email').value.trim() || null,
bio: document.getElementById('set-bio').value.trim(),
bio: document.getElementById('set-bio').value.trim(),
};
};
// Profile color is managed centrally at openvibe.network now (its SSO sync overrides any
// Profile color is managed centrally at openvibe.network now (its SSO sync overrides any
@@ -13,7 +13,7 @@
@@ -13,7 +13,7 @@
* sudo node /opt/openvibe.live/current/scripts/subscribe-media-events.js --disable # rollback
* sudo node /opt/openvibe.live/current/scripts/subscribe-media-events.js --disable # rollback
* sudo node /opt/openvibe.live/current/scripts/subscribe-media-events.js --enable # undo a --disable
* sudo node /opt/openvibe.live/current/scripts/subscribe-media-events.js --enable # undo a --disable
* sudo node /opt/openvibe.live/current/scripts/subscribe-media-events.js --network # Network's
* sudo node /opt/openvibe.live/current/scripts/subscribe-media-events.js --network # Network's
- * network.user.token_valid_after → /internal/network-events (server/auth/network-events.js;
+ * network.user.token_valid_after and network.user.updated → /internal/network-events (server/auth/network-events.js;
* signed with LIVE_EVENTS_SECRET when set, else MEDIA_EVENTS_SECRET); combines with the flags above
* signed with LIVE_EVENTS_SECRET when set, else MEDIA_EVENTS_SECRET); combines with the flags above
*
*
* [--live-env /etc/openvibe/live.env] [--endpoint http://127.0.0.1:3000/internal/media-events]
* [--live-env /etc/openvibe/live.env] [--endpoint http://127.0.0.1:3000/internal/media-events]
@@ -31,7 +31,7 @@ const fs = require('fs');
@@ -31,7 +31,7 @@ const fs = require('fs');
const TOPICS = ['media.vod.*', 'media.clip.*', 'media.storage.*'];
const TOPICS = ['media.vod.*', 'media.clip.*', 'media.storage.*'];
const DEFAULT_ENDPOINT = 'http://127.0.0.1:3000/internal/media-events';
const DEFAULT_ENDPOINT = 'http://127.0.0.1:3000/internal/media-events';
-const NETWORK_TOPICS = ['network.user.token_valid_after'];
+const NETWORK_TOPICS = ['network.user.token_valid_after', 'network.user.updated'];
const NETWORK_ENDPOINT = 'http://127.0.0.1:3000/internal/network-events';
const NETWORK_ENDPOINT = 'http://127.0.0.1:3000/internal/network-events';
const DEFAULT_LIVE_ENV = '/etc/openvibe/live.env';
const DEFAULT_LIVE_ENV = '/etc/openvibe/live.env';
@@ -2,28 +2,33 @@
@@ -2,28 +2,33 @@
/**
/**
* OpenVibe.Events → Live: identity events from OpenVibe.Network (roadmap WS-B task 4).
* OpenVibe.Events → Live: identity events from OpenVibe.Network (roadmap WS-B task 4).
*
*
- * POST /internal/network-events the endpoint of Live's subscription to
- * network.user.token_valid_after (scripts/subscribe-media-events.js
- * --network), signed with LIVE_EVENTS_SECRET, else MEDIA_EVENTS_SECRET
- * (Live's one events secret), signature v2 only.
+ * POST /internal/network-events the endpoint of Live's subscriptions to
+ * network.user.token_valid_after and network.user.updated
+ * (scripts/subscribe-media-events.js --network), signed with LIVE_EVENTS_SECRET,
+ * else MEDIA_EVENTS_SECRET (Live's one events secret), signature v2 only.
*
*
- * Applying is idempotent (a cutoff only ever moves forward), so a redelivery is a no-op without an
- * inbox. Anything signed but not ours to act on is acknowledged (204) so it is not redelivered.
+ * token_valid_after the person's older tokens are refused (./revocations.js)
+ * updated the person's profile, role and ban (./subject-projection.js, WS-B task 2)
+ *
+ * Applying is idempotent (a cutoff only ever moves forward; a profile revision only up), so a redelivery
+ * is a no-op without an inbox. Anything signed but not ours to act on is acknowledged (204).
*/
*/
const revocations = require('./revocations');
const revocations = require('./revocations');
const EVENT_ID_RE = /^evt_[0-9A-HJKMNP-TV-Z]{26}$/;
const EVENT_ID_RE = /^evt_[0-9A-HJKMNP-TV-Z]{26}$/;
const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
-const stats = { received: 0, revoked: 0, unchanged: 0, ignored: 0, refused: 0 };
+const stats = { received: 0, revoked: 0, unchanged: 0, profiles: 0, ignored: 0, refused: 0 };
+const notifyChat = (userId) => { try { const cs = require('../chat/chat-server'); if (cs.remote) cs.userChanged(userId); } catch { /* non-critical */ } };
function secret() { return process.env.LIVE_EVENTS_SECRET || process.env.MEDIA_EVENTS_SECRET || ''; }
function secret() { return process.env.LIVE_EVENTS_SECRET || process.env.MEDIA_EVENTS_SECRET || ''; }
-/** Apply one envelope; returns 'revoked' | 'unchanged' | 'ignored:<why>'. */
+/** Apply one envelope; returns 'revoked' | 'unchanged' | 'updated' | 'stale' | 'ignored:<why>'. */
function apply(ev) {
function apply(ev) {
if (!ev || !EVENT_ID_RE.test(String(ev.event_id || ''))) return 'ignored:envelope';
if (!ev || !EVENT_ID_RE.test(String(ev.event_id || ''))) return 'ignored:envelope';
- if (ev.event_type !== 'network.user.token_valid_after') return 'ignored:type';
+ if (ev.event_type !== 'network.user.token_valid_after' && ev.event_type !== 'network.user.updated') return 'ignored:type';
if (ev.source !== 'network') return 'ignored:source';
if (ev.source !== 'network') return 'ignored:source';
const p = ev.payload && typeof ev.payload === 'object' ? ev.payload : {};
const p = ev.payload && typeof ev.payload === 'object' ? ev.payload : {};
+ if (ev.event_type === 'network.user.updated') return require('./subject-projection').apply(p, { notify: notifyChat });
const subject = p.subject && p.subject.id;
const subject = p.subject && p.subject.id;
const ms = Date.parse(p.valid_after);
const ms = Date.parse(p.valid_after);
if (!SUBJECT_RE.test(String(subject || '')) || !Number.isFinite(ms)) return 'ignored:payload';
if (!SUBJECT_RE.test(String(subject || '')) || !Number.isFinite(ms)) return 'ignored:payload';
@@ -39,7 +44,7 @@ function handler(req, res) {
@@ -39,7 +44,7 @@ function handler(req, res) {
if (!delivery) { stats.refused++; return res.status(401).json({ error: 'bad signature' }); }
if (!delivery) { stats.refused++; return res.status(401).json({ error: 'bad signature' }); }
stats.received++;
stats.received++;
const out = apply(delivery.event);
const out = apply(delivery.event);
- if (out === 'revoked') stats.revoked++; else if (out === 'unchanged') stats.unchanged++; else stats.ignored++;
+ if (out === 'revoked') stats.revoked++; else if (out === 'unchanged' || out === 'stale') stats.unchanged++; else if (out === 'updated') stats.profiles++; else stats.ignored++;
res.status(204).end();
res.status(204).end();
}
}
@@ -50,11 +50,6 @@ function sanitizeDisplayName(raw) {
@@ -50,11 +50,6 @@ function sanitizeDisplayName(raw) {
return s;
return s;
}
}
-function isValidEmail(value) {
- if (!value) return true;
- return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
-}
-
function isAllowedAvatarUrl(value) {
function isAllowedAvatarUrl(value) {
if (!value) return true;
if (!value) return true;
if (value.startsWith('/data/avatars/')) return true;
if (value.startsWith('/data/avatars/')) return true;
@@ -112,7 +107,6 @@ router.put('/profile', requireAuth, (req, res) => {
@@ -112,7 +107,6 @@ router.put('/profile', requireAuth, (req, res) => {
let display_name = cleanOptionalString(req.body.display_name);
let display_name = cleanOptionalString(req.body.display_name);
let bio = cleanOptionalString(req.body.bio);
let bio = cleanOptionalString(req.body.bio);
const avatar_url = cleanOptionalString(req.body.avatar_url);
const avatar_url = cleanOptionalString(req.body.avatar_url);
- const email = cleanOptionalString(req.body.email);
const profile_color = cleanOptionalString(req.body.profile_color);
const profile_color = cleanOptionalString(req.body.profile_color);
const updates = [];
const updates = [];
const params = [];
const params = [];
@@ -133,9 +127,6 @@ router.put('/profile', requireAuth, (req, res) => {
@@ -133,9 +127,6 @@ router.put('/profile', requireAuth, (req, res) => {
if (bio !== undefined && bio.length > 500) {
if (bio !== undefined && bio.length > 500) {
return res.status(400).json({ error: 'Bio must be 500 characters or fewer' });
return res.status(400).json({ error: 'Bio must be 500 characters or fewer' });
}
}
- if (email !== undefined && (!isValidEmail(email) || email.length > 254)) {
- return res.status(400).json({ error: 'Invalid email address' });
- }
if (profile_color !== undefined && profile_color !== '' && !/^#[0-9a-fA-F]{6}$/.test(profile_color)) {
if (profile_color !== undefined && profile_color !== '' && !/^#[0-9a-fA-F]{6}$/.test(profile_color)) {
return res.status(400).json({ error: 'Profile color must be a 6-digit hex color' });
return res.status(400).json({ error: 'Profile color must be a 6-digit hex color' });
}
}
@@ -143,7 +134,7 @@ router.put('/profile', requireAuth, (req, res) => {
@@ -143,7 +134,7 @@ router.put('/profile', requireAuth, (req, res) => {
if (bio !== undefined) { updates.push('bio = ?'); params.push(bio); }
if (bio !== undefined) { updates.push('bio = ?'); params.push(bio); }
// avatar_url is intentionally NOT settable here — avatars change only via the
// avatar_url is intentionally NOT settable here — avatars change only via the
// /api/auth/avatar image upload (from /settings), which validates the file.
// /api/auth/avatar image upload (from /settings), which validates the file.
- if (email !== undefined) { updates.push('email = ?'); params.push(email); }
+ // email is the OpenVibe account's (openvibe.network/my): Live keeps no copy and ignores one sent here (WS-B task 2).
if (profile_color !== undefined) { updates.push('profile_color = ?'); params.push(profile_color); }
if (profile_color !== undefined) { updates.push('profile_color = ?'); params.push(profile_color); }
if (updates.length === 0) {
if (updates.length === 0) {
@@ -383,7 +374,7 @@ function establishNetworkSession(req, res, tokenData) {
@@ -383,7 +374,7 @@ function establishNetworkSession(req, res, tokenData) {
const stream_key = uuidv4().replace(/-/g, '');
const stream_key = uuidv4().replace(/-/g, '');
const result = db.createUser({
const result = db.createUser({
username: ssoUser.username,
username: ssoUser.username,
- email: ssoUser.email || null,
+ email: null, // the OpenVibe account keeps it (WS-B task 2)
password_hash: '$sso$' + require('crypto').randomBytes(32).toString('hex'), // placeholder, can't login with password
password_hash: '$sso$' + require('crypto').randomBytes(32).toString('hex'), // placeholder, can't login with password
display_name: ssoUser.display_name || ssoUser.username,
display_name: ssoUser.display_name || ssoUser.username,
stream_key,
stream_key,
new file mode 100644
new file mode 100644
@@ -0,0 +1,91 @@
@@ -0,0 +1,91 @@
+'use strict';
+/**
+ * What Live knows about a person, kept from OpenVibe.Network's network.user.updated (Contracts 0.43.0,
+ * roadmap WS-B task 2) instead of Live's own copies.
+ *
+ * subject_projection one row per Network subject: the person's current username, display name, picture,
+ * 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
+ * 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 SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+
+let ready = false;
+function ensureSchema() {
+ if (ready) return;
+ db.getDb().exec(`CREATE TABLE IF NOT EXISTS subject_projection (
+ subject_id TEXT PRIMARY KEY,
+ network_user_id INTEGER,
+ revision INTEGER NOT NULL,
+ username TEXT NOT NULL,
+ display_name TEXT,
+ avatar_url TEXT,
+ profile_color TEXT,
+ role TEXT NOT NULL,
+ banned INTEGER NOT NULL DEFAULT 0,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ );
+ CREATE INDEX IF NOT EXISTS idx_subject_projection_network ON subject_projection(network_user_id);`);
+ ready = true;
+}
+
+/** The local account linked to this Network person, or null. */
+function localUser(subject, networkUserId) {
+ const d = db.getDb();
+ const row = d.prepare("SELECT user_id FROM linked_accounts WHERE service = 'network' AND (subject_id = ? OR service_user_id = ?) ORDER BY subject_id = ? DESC LIMIT 1")
+ .get(subject, String(networkUserId), subject);
+ return row ? db.getUserById(row.user_id) : null;
+}
+
+/**
+ * Apply one network.user.updated payload. → 'updated' | 'stale' | 'ignored:payload'
+ * `notify(userId)` is told when the linked account changed (Chat's cache).
+ */
+function apply(p, { notify = () => {} } = {}) {
+ ensureSchema();
+ const subject = p && p.subject && p.subject.id;
+ if (!SUBJECT_RE.test(String(subject || '')) || !Number.isInteger(p.revision) || p.revision < 1 || typeof p.username !== 'string' || !p.username
+ || !ROLES.includes(p.role) || typeof p.banned !== 'boolean') return 'ignored:payload';
+ const d = db.getDb();
+ return d.transaction(() => {
+ const cur = d.prepare('SELECT revision FROM subject_projection WHERE subject_id = ?').get(subject);
+ if (cur && cur.revision >= p.revision) return 'stale';
+ const str = (v, max) => (typeof v === 'string' && v ? v.slice(0, max) : null);
+ d.prepare(`INSERT INTO subject_projection (subject_id, network_user_id, revision, username, display_name, avatar_url, profile_color, role, banned, updated_at)
+ VALUES (@subject, @nid, @rev, @username, @display_name, @avatar_url, @profile_color, @role, @banned, CURRENT_TIMESTAMP)
+ ON CONFLICT(subject_id) DO UPDATE SET network_user_id = excluded.network_user_id, revision = excluded.revision, username = excluded.username,
+ display_name = excluded.display_name, avatar_url = excluded.avatar_url, profile_color = excluded.profile_color, role = excluded.role,
+ banned = excluded.banned, updated_at = excluded.updated_at`)
+ .run({ subject, nid: Number.isInteger(p.network_user_id) ? p.network_user_id : null, rev: p.revision, username: p.username.slice(0, 64),
+ 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 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 });
+ let renamed = false;
+ if (p.username.toLowerCase() !== String(user.username || '').toLowerCase()) {
+ try { renamed = !!require('./usernames').syncUsername(user.id, p.username); } catch { /* the name is taken here: keep the old one */ }
+ }
+ if (changed.length || renamed) notify(user.id);
+ return 'updated';
+ })();
+}
+
+/** The projected person for a subject, or null. */
+function get(subject) {
+ ensureSchema();
+ return db.getDb().prepare('SELECT * FROM subject_projection WHERE subject_id = ?').get(subject) || null;
+}
+
+module.exports = { apply, get, ensureSchema, _reset: () => { ready = false; } };
@@ -2765,6 +2765,10 @@ function getUserByStreamKey(key) {
@@ -2765,6 +2765,10 @@ function getUserByStreamKey(key) {
}
}
function createUser({ username, email, password_hash, display_name, stream_key }) {
function createUser({ username, email, password_hash, display_name, stream_key }) {
+ // Identity is the OpenVibe account's (WS-B task 2): Live stores no password and no email. A real password
+ // hash here is a bug, refused before it reaches the table; an email address is never stored.
+ if (/^\$(2[abxy]?|argon2|scrypt|pbkdf2)/.test(String(password_hash || ''))) throw new Error('Live stores no passwords: accounts sign in through openvibe.network');
+ email = null;
return run(
return run(
`INSERT INTO users (username, email, password_hash, display_name, stream_key)
`INSERT INTO users (username, email, password_hash, display_name, stream_key)
VALUES (?, ?, ?, ?, ?)`,
VALUES (?, ?, ?, ?, ?)`,
@@ -1145,19 +1145,19 @@ async function start() {
@@ -1145,19 +1145,19 @@ async function start() {
// 2. Create admin from .env config if none exists (first-time setup only)
// 2. Create admin from .env config if none exists (first-time setup only)
const adminExists = db.get("SELECT id FROM users WHERE role = 'admin' LIMIT 1");
const adminExists = db.get("SELECT id FROM users WHERE role = 'admin' LIMIT 1");
if (!adminExists) {
if (!adminExists) {
- const bcrypt = require('bcryptjs');
const { v4: uuidv4 } = require('uuid');
const { v4: uuidv4 } = require('uuid');
const adminUser = config.adminUsername || 'admin';
const adminUser = config.adminUsername || 'admin';
- const adminPass = config.adminPassword || 'changeme123';
db.createUser({
db.createUser({
username: adminUser,
username: adminUser,
email: null,
email: null,
- password_hash: bcrypt.hashSync(adminPass, 10),
+ // Live keeps no passwords (sign-in is the OpenVibe account's): this account signs in through SSO once
+ // an openvibe.network account with its username exists; ADMIN_PASSWORD is not used.
+ password_hash: '$sso$' + require('crypto').randomBytes(32).toString('hex'),
display_name: adminUser,
display_name: adminUser,
stream_key: uuidv4().replace(/-/g, ''),
stream_key: uuidv4().replace(/-/g, ''),
});
});
db.run("UPDATE users SET role = 'admin' WHERE username = ?", [adminUser]);
db.run("UPDATE users SET role = 'admin' WHERE username = ?", [adminUser]);
- console.log(`[Server] Admin user "${adminUser}" created from ADMIN_USERNAME — change password after first login!`);
+ console.log(`[Server] Admin user "${adminUser}" created from ADMIN_USERNAME; sign in with the openvibe.network account of that name`);
}
}
// Game & Canvas migrated to openvibe.games — no local init needed
// Game & Canvas migrated to openvibe.games — no local init needed
new file mode 100644
new file mode 100644
@@ -0,0 +1,35 @@
@@ -0,0 +1,35 @@
+'use strict';
+// WS-B task 2: Live keeps no passwords and no email addresses (the OpenVibe account keeps them). The code
+// never hashes a password or writes users.email, and createUser refuses a real hash or an address.
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-live-idcols-'));
+process.env.DB_PATH = path.join(tmp, 'live.db');
+process.env.NODE_ENV = 'test';
+const quiet = console.log; console.log = () => {}; console.warn = () => {};
+const db = require('../server/db/database');
+db.initDb();
+console.log = quiet;
+
+const files = [];
+const walk = (dir) => { for (const e of fs.readdirSync(dir, { withFileTypes: true })) { const p = path.join(dir, e.name); if (e.isDirectory()) { if (e.name !== 'node_modules') walk(p); } else if (p.endsWith('.js')) files.push(p); } };
+walk(path.join(__dirname, '..', 'server'));
+const offenders = [];
+for (const f of files) {
+ const src = fs.readFileSync(f, 'utf8');
+ // server/controls hashes robot and camera control secrets (devices, not accounts): allowed.
+ if (!f.includes(`${path.sep}controls${path.sep}`) && /require\(['"]bcrypt(js)?['"]\)/.test(src)) offenders.push(`${path.relative(process.cwd(), f)}: requires bcrypt`);
+ if (/UPDATE\s+users\s+SET[^;`'"]*\b(email|password_hash)\s*=/i.test(src)) offenders.push(`${path.relative(process.cwd(), f)}: writes users.email or users.password_hash`);
+}
+assert.deepStrictEqual(offenders, [], 'Live never hashes passwords or writes users.email');
+
+const base = { username: 'x1', display_name: 'X', stream_key: 'k1' };
+assert.throws(() => db.createUser({ ...base, password_hash: '$2a$10$abcdefghijklmnopqrstuv' }), /stores no passwords/);
+const id = db.createUser({ ...base, username: 'x2', stream_key: 'k2', email: '[email protected]', password_hash: '$sso$x' }).lastInsertRowid;
+assert.ok(id, 'an SSO account is created');
+assert.strictEqual(db.getUserById(id).email, null, 'an email address given to createUser is not stored');
+fs.rmSync(tmp, { recursive: true, force: true });
+console.log('identity columns: all checks passed');
new file mode 100644
new file mode 100644
@@ -0,0 +1,85 @@
@@ -0,0 +1,85 @@
+'use strict';
+// Live's subject_projection from Network's network.user.updated (WS-B task 2, Contracts 0.43.0): signed
+// deliveries through POST /internal/network-events; the linked account follows the role both ways (a
+// downgrade too), picture, colour, display name and username (with /@old kept); an older revision changes
+// nothing; the local owner keeps admin; a Network ban never touches Live's own ban; someone with no Live
+// account is projected only; a bad payload is ignored; the subscribe script asks for the topic.
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+
+const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-live-subjproj-'));
+process.env.DB_PATH = path.join(tmp, 'live.db');
+process.env.NODE_ENV = 'test';
+process.env.LIVE_EVENTS_SECRET = 's'.repeat(40);
+const quiet = console.log;
+console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
+console.warn = () => {};
+
+const express = require('express');
+const { ids, validate } = require('openvibe-contracts');
+const { signDeliveryHeaders } = require('openvibe-sdk/events');
+const db = require('../server/db/database');
+db.initDb();
+const networkEvents = require('../server/auth/network-events');
+const projection = require('../server/auth/subject-projection');
+const { NETWORK_TOPICS } = require('../scripts/subscribe-media-events');
+
+const ALEX = ids.newId('user'), OWNER = ids.newId('user'), GHOST = ids.newId('user');
+const d = db.getDb();
+d.prepare("INSERT INTO users (id, username, email, password_hash, role, display_name) VALUES (1, 'alex', NULL, '$sso$x', 'admin', 'Alex'), (2, 'boss', NULL, '$sso$y', 'admin', 'Boss')").run();
+d.prepare('UPDATE users SET is_owner = 1 WHERE id = 2').run();
+d.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, service_username, subject_id) VALUES (1, 'network', '7', 'alex', ?), (2, 'network', '1', 'boss', ?)").run(ALEX, OWNER);
+const payload = (subject, nid, revision, over = {}) => ({ subject: { type: 'user', id: subject }, network_user_id: nid, revision, username: 'alex', display_name: 'Alex', avatar_url: null, profile_color: null, role: 'user', banned: false, changed: ['role'], ...over });
+const envelope = (p) => ({ event_id: ids.newId('event'), event_type: 'network.user.updated', version: 1, source: 'network', actor: { type: 'service', id: 'network' },
+ timestamp: new Date().toISOString(), visibility: 'internal', subject: { type: 'user', id: p.subject.id }, payload: p });
+
+(async () => {
+ const app = express();
+ app.use(express.json({ verify: (req, res, buf) => { req.rawBody = buf; } }));
+ app.post('/internal/network-events', networkEvents.handler);
+ const server = http.createServer(app);
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ const url = `http://127.0.0.1:${server.address().port}/internal/network-events`;
+ const deliver = (p) => {
+ const body = JSON.stringify({ event: envelope(p), seq: 1 });
+ return fetch(url, { method: 'POST', headers: { 'content-type': 'application/json', ...signDeliveryHeaders(body, process.env.LIVE_EVENTS_SECRET) }, body }).then((r) => r.status);
+ };
+ const user = (id) => db.getUserById(id);
+ try {
+ const p1 = payload(ALEX, 7, 3, { profile_color: '#22c55e', avatar_url: 'https://openvibe.media/avatar/alex' });
+ 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)');
+ assert.strictEqual(user(1).profile_color, '#22c55e');
+ assert.strictEqual(projection.get(ALEX).revision, 3);
+
+ assert.strictEqual(await deliver(payload(ALEX, 7, 2, { role: 'admin' })), 204);
+ assert.strictEqual(user(1).role, 'user', 'an older revision changes nothing');
+ assert.strictEqual(networkEvents.stats.unchanged, 1);
+
+ assert.strictEqual(await deliver(payload(ALEX, 7, 4, { role: 'streamer', username: 'alex_new', changed: ['username', 'role'] })), 204);
+ assert.strictEqual(user(1).role, 'streamer', 'an upgrade applies');
+ assert.strictEqual(user(1).username, 'alex_new', 'the rename follows');
+
+ 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(user(2).role, 'admin', 'the local owner keeps admin');
+
+ assert.strictEqual(await deliver(payload(GHOST, 99, 1, { username: 'ghost' })), 204);
+ assert.strictEqual(projection.get(GHOST).username, 'ghost', 'someone with no Live account is projected only');
+
+ assert.strictEqual(await deliver(payload(ALEX, 7, 9, { role: 'owner' })), 204);
+ assert.strictEqual(user(1).role, 'streamer', 'a bad payload is ignored');
+ assert.ok(NETWORK_TOPICS.includes('network.user.updated'), 'the subscribe script asks for the topic');
+ } finally {
+ server.close();
+ fs.rmSync(tmp, { recursive: true, force: true });
+ }
+ quiet('subject projection: all checks passed');
+})().catch((err) => { console.error(err); process.exit(1); });