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

OP OpenVibers committed 25 Sept 2026 · f7050296e8 · 13 files, +245 −36


Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex bc114042..99a1e27a 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex 0b4054e6..60961854 100644
---- a/package.json
++++ b/package.json
@@@ -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",
ddiff --git a/public/fragments/dashboard.html b/public/fragments/dashboard.html
iindex 51763ecb..9d8bc264 100644
---- a/public/fragments/dashboard.html
++++ b/public/fragments/dashboard.html
@@@ -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">
ddiff --git a/public/fragments/documentation.html b/public/fragments/documentation.html
iindex 8f986332..a46743d0 100644
---- a/public/fragments/documentation.html
++++ b/public/fragments/documentation.html
@@@ -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>, &le;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>
ddiff --git a/public/js/settings.js b/public/js/settings.js
iindex 3853f5c0..f5c78693 100644
---- a/public/js/settings.js
++++ b/public/js/settings.js
@@@ -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
ddiff --git a/scripts/subscribe-media-events.js b/scripts/subscribe-media-events.js
iindex f8eed304..db847c20 100644
---- a/scripts/subscribe-media-events.js
++++ b/scripts/subscribe-media-events.js
@@@ -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';
ddiff --git a/server/auth/network-events.js b/server/auth/network-events.js
iindex fb9481c4..dbaf03d2 100644
---- a/server/auth/network-events.js
++++ b/server/auth/network-events.js
@@@ -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();
}
ddiff --git a/server/auth/routes.js b/server/auth/routes.js
iindex cb0bdbf8..42c33ba0 100644
---- a/server/auth/routes.js
++++ b/server/auth/routes.js
@@@ -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 password
display_name: ssoUser.display_name || ssoUser.username,
stream_key,
ddiff --git a/server/auth/subject-projection.js b/server/auth/subject-projection.js
nnew file mode 100644
iindex 00000000..e498ffef
---- /dev/null
++++ b/server/auth/subject-projection.js
@@@ -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; } };
ddiff --git a/server/db/database.js b/server/db/database.js
iindex d17dca2e..29f19807 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -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 (?, ?, ?, ?, ?)`,
ddiff --git a/server/index.js b/server/index.js
iindex ef537231..be64e900 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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 needed
ddiff --git a/test/identity-columns.test.js b/test/identity-columns.test.js
nnew file mode 100644
iindex 00000000..bd224efc
---- /dev/null
++++ b/test/identity-columns.test.js
@@@ -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');
ddiff --git a/test/subject-projection.test.js b/test/subject-projection.test.js
nnew file mode 100644
iindex 00000000..a9511226
---- /dev/null
++++ b/test/subject-projection.test.js
@@@ -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); });