openvibers/openvibe.live Commits
Live users -> subject projection, the rest (roadmap WS-B task 2 steps 2, 4 prep and 5): Live no longer reads users.email or users.password_hash, POST /internal/user-role is retired, and the contract step is an operator script
Changes
@@@ -24,6 +24,16 @@ OpenVibe.Live is a self-hosted live streaming platform, one of the OpenVibe servwrites need the area's scope, and money/staff/credential routes refuse tokens.- WebSockets start anonymous or authenticated; `join` can upgrade an anonymous connection, and achange of identity rebuilds the socket (`openvibe-auth-changed` in `public/js/chat.js`).++- The OpenVibe account is the identity. `network.user.updated` at `POST /internal/network-events`++ keeps `subject_projection` and the linked account's username, display name, picture, colour and++ role (`server/auth/subject-projection.js`; `linked_accounts` maps a Live user to its subject).++ `users.role` is that projection's output (Network's staff roles, plus Live's own `streamer`) and is++ read locally. The key-only `POST /internal/user-role` push is gone.++- Live keeps no emails or passwords: nothing writes or reads `users.email` or `users.password_hash`++ (`test/identity-columns.test.js`). The contract step `scripts/identity-columns-contract.js`++ (a dry run by default; `--apply` backs up `live.db` first, never runs on deploy) clears the legacy++ values: every email, and the old hash of every account linked to a Network subject. Accounts with++ no Network identity keep theirs for a future claim flow. The columns themselves stay.## Frontend loadingnnew file mode 100644@@@ -0,0 +1,137 @@++#!/usr/bin/env node++/**++ * The contract step for Live's identity columns (roadmap WS-B task 2 step 4): the OpenVibe account keeps++ * people's email and password, Live stopped writing them (createUser) and reading them++ * (test/identity-columns.test.js), and this clears what is left in live.db.++ *++ * node scripts/identity-columns-contract.js [--db <live.db>]++ * # dry run (the default): counts only, changes nothing++ * node scripts/identity-columns-contract.js --apply [--backup <file.db>] [--db <live.db>]++ * # an online backup first (default <database dir>/backups/live-pre-identity-contract-<time>.db,++ * # mode 0600), then the change in one transaction++ *++ * What --apply changes (server/db/migrations.js operator migration op_001_identity_columns_contract; it is++ * recorded in schema_migrations and runs at most once; it never runs at boot or on deploy):++ * - users.email is set to NULL on every row;++ * - users.password_hash becomes the SSO placeholder ('$sso$' + 64 random hex, as new accounts get; no++ * password matches it) on every account linked to a Network subject whose value is anything else;++ * - accounts with no Network identity keep their value (legacy accounts, kept for a future claim flow),++ * and so does an account whose Network link names no subject Live knows.++ * No column is dropped. Printed: counts and paths only, never an email, hash or username.++ *++ * Default database: $DB_PATH, else data/live.db under $DATA_DIR or the working directory (server/paths.js).++ * In production run it from /opt/openvibe.live/current as the service user (data -> ../../shared/data).++ */++'use strict';++const fs = require('fs');++const path = require('path');++++const ID = 'op_001_identity_columns_contract';++const SSO = "substr(password_hash, 1, 5) <> '$sso$'";++++function parseArgs(argv) {++ const opts = { apply: false, db: null, backup: null, help: false };++ for (let i = 0; i < argv.length; i++) {++ const a = argv[i];++ const next = () => {++ const v = argv[++i];++ if (v === undefined || v.startsWith('--')) throw new Error(`${a} needs a value`);++ return v;++ };++ if (a === '--apply') opts.apply = true;++ else if (a === '--dry-run') opts.apply = false;++ else if (a === '--db') opts.db = next();++ else if (a === '--backup') opts.backup = next();++ else if (a === '--help' || a === '-h') opts.help = true;++ else throw new Error(`unknown option ${a}`);++ }++ return opts;++}++++const hasTable = (db, name) => !!db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name);++++/** What is stored now, as counts. Reads only. */++function counts(db) {++ const { networkLinkedUserIds } = require('../server/db/migrations');++ const links = hasTable(db, 'linked_accounts');++ const linked = links ? networkLinkedUserIds(db) : 'SELECT NULL WHERE 0';++ const anyLink = links ? "SELECT user_id FROM linked_accounts WHERE service = 'network'" : 'SELECT NULL WHERE 0';++ const n = (where) => db.prepare(`SELECT COUNT(*) AS n FROM users WHERE ${where}`).get().n;++ const noIdentity = `${SSO} AND id NOT IN (${anyLink})`;++ return {++ users: n('1'),++ emails: n('email IS NOT NULL'),++ passwords: n(SSO),++ passwords_linked: n(`${SSO} AND id IN (${linked})`),++ passwords_link_without_subject: n(`${SSO} AND id IN (${anyLink}) AND id NOT IN (${linked})`),++ passwords_no_identity: n(noIdentity),++ no_identity_hashes: n(`${noIdentity} AND (password_hash LIKE '$2%' OR password_hash LIKE '$argon2%' OR password_hash LIKE '$scrypt%' OR password_hash LIKE '$pbkdf2%')`),++ no_identity_anon_game: n(`${noIdentity} AND password_hash LIKE '!anon-game:%'`),++ };++}++++function report(c, log, { after = false } = {}) {++ const verb = (what) => (after ? '' : ` -> ${what}`);++ log(`accounts: ${c.users}`);++ log(`emails stored: ${c.emails}${verb('set to NULL')}`);++ log(`password values other than the SSO placeholder: ${c.passwords}`);++ log(` on accounts linked to a Network subject: ${c.passwords_linked}${verb('replaced with the SSO placeholder')}`);++ log(` on accounts with a Network link but no known subject: ${c.passwords_link_without_subject}${verb('kept')}`);++ const other = c.passwords_no_identity - c.no_identity_hashes - c.no_identity_anon_game;++ log(` on accounts with no Network identity: ${c.passwords_no_identity}${verb('kept')} (password hashes ${c.no_identity_hashes}, anon-game placeholders ${c.no_identity_anon_game}, other ${other})`);++}++++function stamp(d = new Date()) {++ return d.toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z').replace('T', '-');++}++++async function main(argv, log = console.log) {++ const opts = parseArgs(argv);++ if (opts.help) {++ const src = fs.readFileSync(__filename, 'utf8');++ log(src.slice(src.indexOf('/**') + 3, src.indexOf('*/')).split('\n').map((l) => l.replace(/^ \* ?/, '')).join('\n').trim());++ return 0;++ }++ const Database = require('better-sqlite3');++ const dbPath = path.resolve(opts.db || require('../server/paths').dbPath());++ if (!fs.existsSync(dbPath)) throw new Error(`no database at ${dbPath}`);++ const backupPath = path.resolve(opts.backup || path.join(path.dirname(dbPath), 'backups', `live-pre-identity-contract-${stamp()}.db`));++ if (opts.apply && fs.existsSync(backupPath)) throw new Error(`${backupPath} already exists; choose a new backup file`);++ const db = new Database(dbPath, { readonly: !opts.apply, fileMustExist: true });++ db.pragma('busy_timeout = 5000');++ try {++ if (!hasTable(db, 'users')) { log('no users table; nothing to do'); return 0; }++ const done = hasTable(db, 'schema_migrations') ? db.prepare('SELECT applied_at FROM schema_migrations WHERE id = ?').get(ID) : null;++ log(`database: ${dbPath}`);++ log(`${ID}: ${done ? `applied at ${done.applied_at} UTC` : 'not run yet'}`);++ const before = counts(db);++ report(before, log, { after: !!done });++ if (done) { log('already applied: nothing to do'); return 0; }++ if (!opts.apply) { log('dry run: nothing changed (add --apply to back up the database and apply)'); return 0; }++++ fs.mkdirSync(path.dirname(backupPath), { recursive: true });++ await db.backup(backupPath);++ fs.chmodSync(backupPath, 0o600); // it holds the emails and hashes this clears++ log(`backup: ${backupPath}`);++++ const res = require('../server/db/migrations').runOperator(db, ID);++ if (res.outcome === 'deferred') { log('users or linked_accounts is missing: nothing changed'); return 1; }++ if (res.outcome !== 'applied') throw new Error(`${ID} ${res.outcome}${res.error ? `: ${res.error}` : ''}`);++ log(`${ID}: applied`);++ log('now:');++ report(counts(db), log, { after: true });++ log(`restore (service stopped): copy ${backupPath} over ${dbPath}, remove any -wal/-shm beside it`);++ return 0;++ } finally {++ db.close();++ }++}++++if (require.main === module) {++ main(process.argv.slice(2)).then((code) => process.exit(code), (err) => {++ console.error(`error: ${err.message}`);++ process.exit(1);++ });++}++++module.exports = { main, counts, parseArgs, ID };@@@ -124,7 +124,9 @@ router.get('/users', (req, res) => {const offset = parseInt(req.query.offset || '0');const search = req.query.search || '';-- let sql = `SELECT u.id, u.username, u.display_name, u.email, u.role, u.openvibe_bucks_balance,++ // No email here: the OpenVibe account keeps it (Network's admin finds people by address), and Live++ // no longer reads users.email (WS-B task 2). Staff find people by username or display name.++ let sql = `SELECT u.id, u.username, u.display_name, u.role, u.openvibe_bucks_balance,u.is_banned, u.ban_reason, u.created_at, u.last_seen,COALESCE(c.force_vod_recording_disabled, 0) AS force_vod_recording_disabledFROM users u@@@ -132,14 +134,14 @@ router.get('/users', (req, res) => {const params = [];if (search) {-- sql += ' WHERE u.username LIKE ? OR u.display_name LIKE ? OR u.email LIKE ?';-- params.push(`%${search}%`, `%${search}%`, `%${search}%`);++ sql += ' WHERE u.username LIKE ? OR u.display_name LIKE ?';++ params.push(`%${search}%`, `%${search}%`);}const countSql = search-- ? `SELECT COUNT(*) as c FROM users WHERE username LIKE ? OR display_name LIKE ? OR email LIKE ?`++ ? `SELECT COUNT(*) as c FROM users WHERE username LIKE ? OR display_name LIKE ?`: `SELECT COUNT(*) as c FROM users`;-- const countParams = search ? [`%${search}%`, `%${search}%`, `%${search}%`] : [];++ const countParams = search ? [`%${search}%`, `%${search}%`] : [];sql += ' ORDER BY u.created_at DESC LIMIT ? OFFSET ?';params.push(limit, offset);@@@ -230,8 +232,8 @@ router.put('/users/:id', (req, res) => {}const user = db.getUserById(req.params.id);-- // Sanitize — never expose password_hash or stream_key-- const { password_hash, stream_key, ...safeUser } = user;++ // Sanitize — never expose password_hash, email (legacy columns, WS-B task 2) or stream_key++ const { password_hash, email, stream_key, ...safeUser } = user;// Push real-time update to the affected user's chat connectionsif (updates.length > 0) {@@@ -111,8 +111,8 @@ function _syncSsoUserFields(user, decoded) {// Sync role from the SSO token, but NEVER downgrade based on it. Access tokens// live 24h, so a just-promoted admin's older token still carries role:'user'// and would otherwise strip their role (and Staff badge) on every connect.-- // Upgrades apply immediately; downgrades are pushed authoritatively from-- // openvibe.network via POST /internal/user-role instead of trusting a stale token.++ // Upgrades apply immediately; downgrades arrive from openvibe.network as++ // network.user.updated (server/auth/subject-projection.js) instead of trusting a stale token.if (decoded.role && _ROLE_RANK[decoded.role] !== undefined && decoded.role !== user.role&& _ROLE_RANK[decoded.role] > (_ROLE_RANK[user.role] ?? 0)) {updates.push('role = ?'); params.push(decoded.role);@@@ -661,12 +661,12 @@ function sanitizeUser(user, publicOnly = false) {capabilities: permissions.getCapabilities(user),};if (!publicOnly) {-- // Balances are real money (Vibes) and the user's own business, like email and the key.++ // Balances are real money (Vibes) and the user's own business, like the key. No email: the OpenVibe++ // account keeps it (openvibe.network/my) and Live does not read users.email (WS-B task 2).// Under BILLING_AUTHORITY=billing the column is a frozen legacy copy (ADR-012 rule 8: never// shown as live); the balance comes from /api/funds/balance.if (!require('../monetization/money-authority').onBilling()) safe.openvibe_bucks_balance = user.openvibe_bucks_balance;safe.openvibe_coins_balance = user.openvibe_coins_balance;-- safe.email = user.email;safe.stream_key = user.stream_key;}return safe;@@@ -7,16 +7,27 @@* 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).*++ * Which Live account is the person's: linked_accounts (service 'network'), by subject_id, else by the Network++ * user id in service_user_id (localUser below). An account with no such row has no Network identity (a legacy++ * local account, kept for a future claim flow).++ ** 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;++ * both ways (a downgrade too, which the token sync in auth.js never does; this is the only path for one++ * since the key-only /internal/user-role push was retired), 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.++ *++ * users.role is therefore this projection's output: Network's staff roles (the staff map) applied by the++ * rules above, plus Live's own `streamer` (and a grant from Live's admin panel, until Network next changes++ * that person's role). Live code reads users.role (through permissions.js) locally, which is reading the++ * projection; it stays a column. Identity itself stays on the OpenVibe account: Live neither stores nor++ * reads users.email or users.password_hash (test/identity-columns.test.js), and the operator script++ * scripts/identity-columns-contract.js clears the legacy values (WS-B task 2 step 4).*/const db = require('../db/database');@@@ -105,6 +105,42 @@ const MIGRATIONS = [},];++/**++ * SQL for the ids of Live accounts linked to a Network subject: a linked_accounts row (service 'network') that++ * names the subject, or whose Network user id subject_projection knows (server/auth/subject-projection.js++ * resolves a person the same way). An account without one has no Network identity.++ */++function networkLinkedUserIds(db) {++ const viaProjection = tableExists(db, 'subject_projection')++ ? ' OR EXISTS (SELECT 1 FROM subject_projection sp WHERE sp.network_user_id = CAST(la.service_user_id AS INTEGER))'++ : '';++ return `SELECT la.user_id FROM linked_accounts la WHERE la.service = 'network' AND (substr(COALESCE(la.subject_id, ''), 1, 4) = 'usr_'${viaProjection})`;++}++++/**++ * Operator migrations: contract steps (expand/migrate/contract, ADR-028) that clear data the release before++ * might still want, so they never run at boot: run() from initDb() walks MIGRATIONS only. An operator script++ * runs one with runOperator() after taking its own backup; the ledger and the transaction are the same, so it++ * runs at most once and is recorded like any other. Append only, like MIGRATIONS; ids start with `op_`.++ */++const OPERATOR_MIGRATIONS = [++ {++ id: 'op_001_identity_columns_contract',++ // WS-B task 2 step 4 (scripts/identity-columns-contract.js). The OpenVibe account keeps the email and the++ // password; Live stopped writing them (createUser) and reading them (test/identity-columns.test.js). This++ // clears what is left: every stored email, and every password value other than the SSO placeholder on an++ // account linked to a Network subject (that person signs in through openvibe.network). Accounts with no++ // Network identity keep their legacy hash for a future claim flow. The columns stay (no DROP COLUMN:++ // SELECT * readers); NOT NULL password_hash gets the same '$sso$' + 64 hex placeholder as new accounts.++ up: (db) => {++ if (!tableExists(db, 'users') || !tableExists(db, 'linked_accounts')) return DEFER;++ db.prepare('UPDATE users SET email = NULL WHERE email IS NOT NULL').run();++ db.prepare(`UPDATE users SET password_hash = '$sso$' || lower(hex(randomblob(32)))++ WHERE substr(password_hash, 1, 5) <> '$sso$' AND id IN (${networkLinkedUserIds(db)})`).run();++ },++ },++];++const failures = new Map(); // id -> message, for this processfunction run(db, list = MIGRATIONS) {@@@ -144,4 +180,17 @@ function getStatus(db, list = MIGRATIONS) {return list.map((m) => rows.get(m.id) || { id: m.id, mode: failures.has(m.id) ? 'failed' : 'pending', error: failures.get(m.id) || null });}--module.exports = { MIGRATIONS, DEFER, run, getStatus };++/**++ * Run one operator migration now (never called at boot). → { id, outcome: 'applied' | 'deferred' | 'failed'++ * | 'already', error?, applied_at? }++ */++function runOperator(db, id, list = OPERATOR_MIGRATIONS) {++ const m = list.find((x) => x.id === id);++ if (!m) throw new Error(`no operator migration ${id}`);++ const res = run(db, [m])[0];++ if (res) return res;++ const row = db.prepare('SELECT applied_at FROM schema_migrations WHERE id = ?').get(id);++ return { id, outcome: 'already', applied_at: row ? row.applied_at : null };++}++++module.exports = { MIGRATIONS, OPERATOR_MIGRATIONS, DEFER, run, getStatus, runOperator, networkLinkedUserIds };@@@ -72,10 +72,10 @@ router.post('/url-registry/refresh', async (req, res) => {}});--// Authoritative role push from openvibe.network (the SSO/role authority). Used so a--// role change propagates to the local user record immediately — instead of--// waiting on the user's next token (up to 24h) and without letting a stale token--// downgrade them. Matches by openvibe.network account link first, then username.++// Roles are no longer pushed here: POST /internal/user-role (key-only, register C-54/C-55) was retired in++// WS-B task 2. A role change on the Network reaches Live as network.user.updated at POST /internal/network-events++// (server/auth/subject-projection.js), which also applies downgrades.++// ── The account's avatar changed on the Network (or on another site) ─────────// The avatar is a network-wide property (OpenVibe.Network server/profile/avatar.js). Live keeps a copy on its// own user row because every stream card, chat line and profile reads it locally.@@@ -104,32 +104,4 @@ router.post('/user-avatar', (req, res) => {}});--router.post('/user-role', (req, res) => {-- try {-- const { username, openvibenetwork_id, role } = req.body || {};-- const VALID = ['user', 'streamer', 'global_mod', 'admin'];-- if (!VALID.includes(role)) return res.status(400).json({ ok: false, error: 'invalid role' });---- let user = null;-- if (openvibenetwork_id != null) {-- const linked = db.getDb().prepare(-- "SELECT user_id FROM linked_accounts WHERE service = 'network' AND service_user_id = ?"-- ).get(String(openvibenetwork_id));-- if (linked) user = db.getUserById(linked.user_id);-- }-- if (!user && username) user = db.getUserByUsername(username);-- if (!user) return res.status(404).json({ ok: false, error: 'user not found' });---- // Never strip the owner's admin role via a role push (is_owner is local).-- const finalRole = (user.is_owner && role !== 'admin') ? 'admin' : role;-- db.getDb().prepare('UPDATE users SET role = ? WHERE id = ?').run(finalRole, user.id);-- notifyChat(user.id);-- console.log(`[Internal] role push: ${user.username} -> ${finalRole}`);-- return res.json({ ok: true, id: user.id, username: user.username, role: finalRole });-- } catch (err) {-- console.error('[Internal] user-role error:', err.message);-- return res.status(500).json({ ok: false, error: err.message });-- }--});--module.exports = router;@@@ -320,7 +320,7 @@ router.post('/cashout/:id/deny', requireOwner, money.guardWrite, (req, res) => {router.get('/cashouts/pending', requireOwner, (req, res) => {if (money.onBilling()) return cashoutsInBilling(res);const pending = db.all(`-- SELECT t.*, u.username, u.display_name, u.email++ SELECT t.*, u.username, u.display_nameFROM transactions tJOIN users u ON t.from_user_id = u.idWHERE t.type = 'cashout' AND t.status = 'escrow'@@@ -1,6 +1,9 @@'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.++// never hashes a password, never writes or reads users.email or users.password_hash (the frozen-column++// tripwire), and createUser refuses a real hash or an address. The one exception is the contract step,++// scripts/identity-columns-contract.js (operator migration op_001_identity_columns_contract): it clears the++// legacy values, only when an operator runs it, after a backup, and never at boot.const assert = require('assert');const fs = require('fs');const os = require('os');@@@ -17,19 +20,133 @@ 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 MIGRATIONS = path.join('server', 'db', 'migrations.js');++// The contract step's own writes (and nothing else in that file).++const CONTRACT_WRITES = ['UPDATE users SET email = NULL', "UPDATE users SET password_hash = '$sso$'"];++// Reads of the legacy columns: a users alias or a user object's field, or SQL selecting or filtering them on users.++// camera_profiles.password_hash (ONVIF device credentials, server/controls) is another table: `camera.password_hash`.++const READS = [++ /\bu\.(email|password_hash)\b/,++ /\b\w*[uU]ser\.(email|password_hash)\b/,++ /SELECT\b[^;`'"]*\b(email|password_hash)\b[^;`'"]*\bFROM\s+users\b/i,++ /\bFROM\s+users\b[^;`'"]*\b(email|password_hash)\b/i,++];const offenders = [];for (const f of files) {++ const rel = path.relative(path.join(__dirname, '..'), f);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`);++ if (!f.includes(`${path.sep}controls${path.sep}`) && /require\(['"]bcrypt(js)?['"]\)/.test(src)) offenders.push(`${rel}: requires bcrypt`);++ for (const m of src.matchAll(/UPDATE\s+users\s+SET[^;`'"]*\b(email|password_hash)\s*=\s*('\$sso\$'|NULL)?/gi)) {++ const stmt = m[0].replace(/\s+/g, ' ');++ if (!(rel === MIGRATIONS && CONTRACT_WRITES.includes(stmt))) offenders.push(`${rel}: writes users.${m[1]} (${stmt})`);++ }++ for (const re of READS) { const m = src.match(re); if (m) offenders.push(`${rel}: reads users.${m[1]} (${m[0].replace(/\s+/g, ' ').slice(0, 80)})`); }}--assert.deepStrictEqual(offenders, [], 'Live never hashes passwords or writes users.email');++assert.deepStrictEqual(offenders, [], 'Live never hashes passwords or writes or reads users.email / users.password_hash');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');++++// ── The contract step (scripts/identity-columns-contract.js) ─────────────────────++const raw = db.getDb();++const migrations = require('../server/db/migrations');++const contract = require('../scripts/identity-columns-contract');++const BCRYPT = '$2a$10$' + 'x'.repeat(53); // bcrypt-shaped, obviously fake++const SSO_KEPT = '$sso$' + 'ab'.repeat(32);++const addUser = (name, email, hash) => Number(raw.prepare('INSERT INTO users (username, display_name, email, password_hash, stream_key) VALUES (?, ?, ?, ?, ?)')++ .run(name, name, email, hash, `sk-${name}`).lastInsertRowid);++const link = (userId, networkId, subject) => raw.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, service_username, subject_id) VALUES (?, 'network', ?, 'x', ?)")++ .run(userId, String(networkId), subject);++const linkedSub = addUser('linked_sub', '[email protected]', BCRYPT); // linked, the subject on the link++link(linkedSub, 101, 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA');++const linkedProj = addUser('linked_proj', '[email protected]', BCRYPT); // linked, the subject only in the projection++link(linkedProj, 102, null);++require('../server/auth/subject-projection').ensureSchema();++raw.prepare("INSERT INTO subject_projection (subject_id, network_user_id, revision, username, role) VALUES ('usr_01JAB2C3D4E5F6G7H8J9K0MNPB', 102, 1, 'linked_proj', 'user')").run();++const linkedNoSub = addUser('linked_nosub', null, BCRYPT); // a link that names no known subject: kept++link(linkedNoSub, 103, null);++const legacy = addUser('legacy_local', '[email protected]', BCRYPT); // no Network identity: kept++const anonGame = addUser('anon_game', null, '!anon-game:k:0123'); // a guest placeholder: kept++const ssoAlready = addUser('sso_already', '[email protected]', SSO_KEPT); // already the placeholder: untouched++link(ssoAlready, 104, 'usr_01JAB2C3D4E5F6G7H8J9K0MNPC');++const row = (uid) => raw.prepare('SELECT email, password_hash FROM users WHERE id = ?').get(uid);++const snapshot = () => raw.prepare('SELECT id, email, password_hash FROM users ORDER BY id').all();++++// Boot never runs it.++const before = snapshot();++console.log = () => {};++db.initDb(); migrations.run(raw);++console.log = quiet;++assert.deepStrictEqual(snapshot(), before, 'boot migrations leave the identity columns alone');++assert.ok(!raw.prepare('SELECT 1 FROM schema_migrations WHERE id = ?').get(contract.ID), 'and do not record the contract step');++assert.ok(!migrations.MIGRATIONS.some((m) => m.id === contract.ID) && migrations.OPERATOR_MIGRATIONS.some((m) => m.id === contract.ID));++++const secrets = ['[email protected]', '[email protected]', '[email protected]', '[email protected]', BCRYPT, SSO_KEPT, 'linked_sub', 'legacy_local'];++const run = async (argv) => {++ const lines = [];++ console.log = () => {}; // the migration runner's own [DB] line++ try { return { code: await contract.main(argv, (l) => lines.push(String(l))), out: lines.join('\n') }; } finally { console.log = quiet; }++};++const noValues = (out) => { for (const s of secrets) assert.ok(!out.includes(s), 'the script prints counts only'); };++++(async () => {++ const dbArg = ['--db', process.env.DB_PATH];++ // Dry run (the default): counts, nothing changed.++ let r = await run(dbArg);++ assert.strictEqual(r.code, 0);++ noValues(r.out);++ assert.match(r.out, /not run yet/);++ assert.match(r.out, /emails stored: 4 -> set to NULL/);++ assert.match(r.out, /other than the SSO placeholder: 5\b/);++ assert.match(r.out, /linked to a Network subject: 2 -> replaced/);++ assert.match(r.out, /Network link but no known subject: 1 -> kept/);++ assert.match(r.out, /no Network identity: 2 -> kept \(password hashes 1, anon-game placeholders 1, other 0\)/);++ assert.match(r.out, /dry run: nothing changed/);++ assert.deepStrictEqual(snapshot(), before, 'a dry run changes nothing');++ assert.deepStrictEqual(contract.parseArgs(['--dry-run']).apply, false);++++ // Apply: backup first (0600, still holding the old values), then the change, recorded once.++ const backup = path.join(tmp, 'backups', 'pre.db');++ r = await run([...dbArg, '--apply', '--backup', backup]);++ assert.strictEqual(r.code, 0, r.out);++ noValues(r.out);++ assert.ok(fs.existsSync(backup), 'a backup is taken');++ assert.strictEqual(fs.statSync(backup).mode & 0o777, 0o600);++ const Database = require('better-sqlite3');++ const b = new Database(backup, { readonly: true });++ assert.strictEqual(b.prepare('SELECT COUNT(*) AS n FROM users WHERE email IS NOT NULL').get().n, 4, 'the backup has the old values');++ b.close();++++ assert.strictEqual(raw.prepare('SELECT COUNT(*) AS n FROM users WHERE email IS NOT NULL').get().n, 0, 'every email is cleared');++ for (const uid of [linkedSub, linkedProj]) {++ assert.match(row(uid).password_hash, /^\$sso\$[0-9a-f]{64}$/, 'a linked account gets the SSO placeholder');++ }++ assert.notStrictEqual(row(linkedSub).password_hash, row(linkedProj).password_hash, 'each placeholder is random');++ assert.strictEqual(row(linkedNoSub).password_hash, BCRYPT, 'a link without a known subject keeps its value');++ assert.strictEqual(row(legacy).password_hash, BCRYPT, 'an account with no Network identity keeps its hash');++ assert.strictEqual(row(anonGame).password_hash, '!anon-game:k:0123');++ assert.strictEqual(row(ssoAlready).password_hash, SSO_KEPT, 'a placeholder is left as it was');++ assert.strictEqual(raw.prepare('SELECT mode FROM schema_migrations WHERE id = ?').get(contract.ID).mode, 'applied');++ assert.match(r.out, /now:[\s\S]*emails stored: 0[\s\S]*linked to a Network subject: 0/);++++ // Once only.++ const after = snapshot();++ r = await run([...dbArg, '--apply', '--backup', path.join(tmp, 'backups', 'again.db')]);++ assert.strictEqual(r.code, 0);++ assert.match(r.out, /already applied: nothing to do/);++ assert.ok(!fs.existsSync(path.join(tmp, 'backups', 'again.db')), 'nothing to do, no backup');++ assert.deepStrictEqual(snapshot(), after);++ assert.strictEqual(migrations.runOperator(raw, contract.ID).outcome, 'already');++ assert.throws(() => migrations.runOperator(raw, 'op_999_nope'), /no operator migration/);++++ // An existing backup file is never overwritten.++ raw.prepare("DELETE FROM schema_migrations WHERE id = ?").run(contract.ID);++ await assert.rejects(run([...dbArg, '--apply', '--backup', backup]), /already exists/);++++ fs.rmSync(tmp, { recursive: true, force: true });++ console.log('identity columns: all checks passed');++})().catch((e) => { console.error(e); process.exit(1); });
@@ -24,6 +24,16 @@ OpenVibe.Live is a self-hosted live streaming platform, one of the OpenVibe serv
@@ -24,6 +24,16 @@ OpenVibe.Live is a self-hosted live streaming platform, one of the OpenVibe serv
writes need the area's scope, and money/staff/credential routes refuse tokens.
writes need the area's scope, and money/staff/credential routes refuse tokens.
- WebSockets start anonymous or authenticated; `join` can upgrade an anonymous connection, and a
- WebSockets start anonymous or authenticated; `join` can upgrade an anonymous connection, and a
change of identity rebuilds the socket (`openvibe-auth-changed` in `public/js/chat.js`).
change of identity rebuilds the socket (`openvibe-auth-changed` in `public/js/chat.js`).
+- The OpenVibe account is the identity. `network.user.updated` at `POST /internal/network-events`
+ keeps `subject_projection` and the linked account's username, display name, picture, colour and
+ role (`server/auth/subject-projection.js`; `linked_accounts` maps a Live user to its subject).
+ `users.role` is that projection's output (Network's staff roles, plus Live's own `streamer`) and is
+ read locally. The key-only `POST /internal/user-role` push is gone.
+- Live keeps no emails or passwords: nothing writes or reads `users.email` or `users.password_hash`
+ (`test/identity-columns.test.js`). The contract step `scripts/identity-columns-contract.js`
+ (a dry run by default; `--apply` backs up `live.db` first, never runs on deploy) clears the legacy
+ values: every email, and the old hash of every account linked to a Network subject. Accounts with
+ no Network identity keep theirs for a future claim flow. The columns themselves stay.
## Frontend loading
## Frontend loading
new file mode 100644
new file mode 100644
@@ -0,0 +1,137 @@
@@ -0,0 +1,137 @@
+#!/usr/bin/env node
+/**
+ * The contract step for Live's identity columns (roadmap WS-B task 2 step 4): the OpenVibe account keeps
+ * people's email and password, Live stopped writing them (createUser) and reading them
+ * (test/identity-columns.test.js), and this clears what is left in live.db.
+ *
+ * node scripts/identity-columns-contract.js [--db <live.db>]
+ * # dry run (the default): counts only, changes nothing
+ * node scripts/identity-columns-contract.js --apply [--backup <file.db>] [--db <live.db>]
+ * # an online backup first (default <database dir>/backups/live-pre-identity-contract-<time>.db,
+ * # mode 0600), then the change in one transaction
+ *
+ * What --apply changes (server/db/migrations.js operator migration op_001_identity_columns_contract; it is
+ * recorded in schema_migrations and runs at most once; it never runs at boot or on deploy):
+ * - users.email is set to NULL on every row;
+ * - users.password_hash becomes the SSO placeholder ('$sso$' + 64 random hex, as new accounts get; no
+ * password matches it) on every account linked to a Network subject whose value is anything else;
+ * - accounts with no Network identity keep their value (legacy accounts, kept for a future claim flow),
+ * and so does an account whose Network link names no subject Live knows.
+ * No column is dropped. Printed: counts and paths only, never an email, hash or username.
+ *
+ * Default database: $DB_PATH, else data/live.db under $DATA_DIR or the working directory (server/paths.js).
+ * In production run it from /opt/openvibe.live/current as the service user (data -> ../../shared/data).
+ */
+'use strict';
+const fs = require('fs');
+const path = require('path');
+
+const ID = 'op_001_identity_columns_contract';
+const SSO = "substr(password_hash, 1, 5) <> '$sso$'";
+
+function parseArgs(argv) {
+ const opts = { apply: false, db: null, backup: null, help: false };
+ for (let i = 0; i < argv.length; i++) {
+ const a = argv[i];
+ const next = () => {
+ const v = argv[++i];
+ if (v === undefined || v.startsWith('--')) throw new Error(`${a} needs a value`);
+ return v;
+ };
+ if (a === '--apply') opts.apply = true;
+ else if (a === '--dry-run') opts.apply = false;
+ else if (a === '--db') opts.db = next();
+ else if (a === '--backup') opts.backup = next();
+ else if (a === '--help' || a === '-h') opts.help = true;
+ else throw new Error(`unknown option ${a}`);
+ }
+ return opts;
+}
+
+const hasTable = (db, name) => !!db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name);
+
+/** What is stored now, as counts. Reads only. */
+function counts(db) {
+ const { networkLinkedUserIds } = require('../server/db/migrations');
+ const links = hasTable(db, 'linked_accounts');
+ const linked = links ? networkLinkedUserIds(db) : 'SELECT NULL WHERE 0';
+ const anyLink = links ? "SELECT user_id FROM linked_accounts WHERE service = 'network'" : 'SELECT NULL WHERE 0';
+ const n = (where) => db.prepare(`SELECT COUNT(*) AS n FROM users WHERE ${where}`).get().n;
+ const noIdentity = `${SSO} AND id NOT IN (${anyLink})`;
+ return {
+ users: n('1'),
+ emails: n('email IS NOT NULL'),
+ passwords: n(SSO),
+ passwords_linked: n(`${SSO} AND id IN (${linked})`),
+ passwords_link_without_subject: n(`${SSO} AND id IN (${anyLink}) AND id NOT IN (${linked})`),
+ passwords_no_identity: n(noIdentity),
+ no_identity_hashes: n(`${noIdentity} AND (password_hash LIKE '$2%' OR password_hash LIKE '$argon2%' OR password_hash LIKE '$scrypt%' OR password_hash LIKE '$pbkdf2%')`),
+ no_identity_anon_game: n(`${noIdentity} AND password_hash LIKE '!anon-game:%'`),
+ };
+}
+
+function report(c, log, { after = false } = {}) {
+ const verb = (what) => (after ? '' : ` -> ${what}`);
+ log(`accounts: ${c.users}`);
+ log(`emails stored: ${c.emails}${verb('set to NULL')}`);
+ log(`password values other than the SSO placeholder: ${c.passwords}`);
+ log(` on accounts linked to a Network subject: ${c.passwords_linked}${verb('replaced with the SSO placeholder')}`);
+ log(` on accounts with a Network link but no known subject: ${c.passwords_link_without_subject}${verb('kept')}`);
+ const other = c.passwords_no_identity - c.no_identity_hashes - c.no_identity_anon_game;
+ log(` on accounts with no Network identity: ${c.passwords_no_identity}${verb('kept')} (password hashes ${c.no_identity_hashes}, anon-game placeholders ${c.no_identity_anon_game}, other ${other})`);
+}
+
+function stamp(d = new Date()) {
+ return d.toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z').replace('T', '-');
+}
+
+async function main(argv, log = console.log) {
+ const opts = parseArgs(argv);
+ if (opts.help) {
+ const src = fs.readFileSync(__filename, 'utf8');
+ log(src.slice(src.indexOf('/**') + 3, src.indexOf('*/')).split('\n').map((l) => l.replace(/^ \* ?/, '')).join('\n').trim());
+ return 0;
+ }
+ const Database = require('better-sqlite3');
+ const dbPath = path.resolve(opts.db || require('../server/paths').dbPath());
+ if (!fs.existsSync(dbPath)) throw new Error(`no database at ${dbPath}`);
+ const backupPath = path.resolve(opts.backup || path.join(path.dirname(dbPath), 'backups', `live-pre-identity-contract-${stamp()}.db`));
+ if (opts.apply && fs.existsSync(backupPath)) throw new Error(`${backupPath} already exists; choose a new backup file`);
+ const db = new Database(dbPath, { readonly: !opts.apply, fileMustExist: true });
+ db.pragma('busy_timeout = 5000');
+ try {
+ if (!hasTable(db, 'users')) { log('no users table; nothing to do'); return 0; }
+ const done = hasTable(db, 'schema_migrations') ? db.prepare('SELECT applied_at FROM schema_migrations WHERE id = ?').get(ID) : null;
+ log(`database: ${dbPath}`);
+ log(`${ID}: ${done ? `applied at ${done.applied_at} UTC` : 'not run yet'}`);
+ const before = counts(db);
+ report(before, log, { after: !!done });
+ if (done) { log('already applied: nothing to do'); return 0; }
+ if (!opts.apply) { log('dry run: nothing changed (add --apply to back up the database and apply)'); return 0; }
+
+ fs.mkdirSync(path.dirname(backupPath), { recursive: true });
+ await db.backup(backupPath);
+ fs.chmodSync(backupPath, 0o600); // it holds the emails and hashes this clears
+ log(`backup: ${backupPath}`);
+
+ const res = require('../server/db/migrations').runOperator(db, ID);
+ if (res.outcome === 'deferred') { log('users or linked_accounts is missing: nothing changed'); return 1; }
+ if (res.outcome !== 'applied') throw new Error(`${ID} ${res.outcome}${res.error ? `: ${res.error}` : ''}`);
+ log(`${ID}: applied`);
+ log('now:');
+ report(counts(db), log, { after: true });
+ log(`restore (service stopped): copy ${backupPath} over ${dbPath}, remove any -wal/-shm beside it`);
+ return 0;
+ } finally {
+ db.close();
+ }
+}
+
+if (require.main === module) {
+ main(process.argv.slice(2)).then((code) => process.exit(code), (err) => {
+ console.error(`error: ${err.message}`);
+ process.exit(1);
+ });
+}
+
+module.exports = { main, counts, parseArgs, ID };
@@ -124,7 +124,9 @@ router.get('/users', (req, res) => {
@@ -124,7 +124,9 @@ router.get('/users', (req, res) => {
const offset = parseInt(req.query.offset || '0');
const offset = parseInt(req.query.offset || '0');
const search = req.query.search || '';
const search = req.query.search || '';
- let sql = `SELECT u.id, u.username, u.display_name, u.email, u.role, u.openvibe_bucks_balance,
+ // No email here: the OpenVibe account keeps it (Network's admin finds people by address), and Live
+ // no longer reads users.email (WS-B task 2). Staff find people by username or display name.
+ let sql = `SELECT u.id, u.username, u.display_name, u.role, u.openvibe_bucks_balance,
u.is_banned, u.ban_reason, u.created_at, u.last_seen,
u.is_banned, u.ban_reason, u.created_at, u.last_seen,
COALESCE(c.force_vod_recording_disabled, 0) AS force_vod_recording_disabled
COALESCE(c.force_vod_recording_disabled, 0) AS force_vod_recording_disabled
FROM users u
FROM users u
@@ -132,14 +134,14 @@ router.get('/users', (req, res) => {
@@ -132,14 +134,14 @@ router.get('/users', (req, res) => {
const params = [];
const params = [];
if (search) {
if (search) {
- sql += ' WHERE u.username LIKE ? OR u.display_name LIKE ? OR u.email LIKE ?';
- params.push(`%${search}%`, `%${search}%`, `%${search}%`);
+ sql += ' WHERE u.username LIKE ? OR u.display_name LIKE ?';
+ params.push(`%${search}%`, `%${search}%`);
}
}
const countSql = search
const countSql = search
- ? `SELECT COUNT(*) as c FROM users WHERE username LIKE ? OR display_name LIKE ? OR email LIKE ?`
+ ? `SELECT COUNT(*) as c FROM users WHERE username LIKE ? OR display_name LIKE ?`
: `SELECT COUNT(*) as c FROM users`;
: `SELECT COUNT(*) as c FROM users`;
- const countParams = search ? [`%${search}%`, `%${search}%`, `%${search}%`] : [];
+ const countParams = search ? [`%${search}%`, `%${search}%`] : [];
sql += ' ORDER BY u.created_at DESC LIMIT ? OFFSET ?';
sql += ' ORDER BY u.created_at DESC LIMIT ? OFFSET ?';
params.push(limit, offset);
params.push(limit, offset);
@@ -230,8 +232,8 @@ router.put('/users/:id', (req, res) => {
@@ -230,8 +232,8 @@ router.put('/users/:id', (req, res) => {
}
}
const user = db.getUserById(req.params.id);
const user = db.getUserById(req.params.id);
- // Sanitize — never expose password_hash or stream_key
- const { password_hash, stream_key, ...safeUser } = user;
+ // Sanitize — never expose password_hash, email (legacy columns, WS-B task 2) or stream_key
+ const { password_hash, email, stream_key, ...safeUser } = user;
// Push real-time update to the affected user's chat connections
// Push real-time update to the affected user's chat connections
if (updates.length > 0) {
if (updates.length > 0) {
@@ -111,8 +111,8 @@ function _syncSsoUserFields(user, decoded) {
@@ -111,8 +111,8 @@ function _syncSsoUserFields(user, decoded) {
// Sync role from the SSO token, but NEVER downgrade based on it. Access tokens
// Sync role from the SSO token, but NEVER downgrade based on it. Access tokens
// live 24h, so a just-promoted admin's older token still carries role:'user'
// live 24h, so a just-promoted admin's older token still carries role:'user'
// and would otherwise strip their role (and Staff badge) on every connect.
// and would otherwise strip their role (and Staff badge) on every connect.
- // Upgrades apply immediately; downgrades are pushed authoritatively from
- // openvibe.network via POST /internal/user-role instead of trusting a stale token.
+ // Upgrades apply immediately; downgrades arrive from openvibe.network as
+ // network.user.updated (server/auth/subject-projection.js) instead of trusting a stale token.
if (decoded.role && _ROLE_RANK[decoded.role] !== undefined && decoded.role !== user.role
if (decoded.role && _ROLE_RANK[decoded.role] !== undefined && decoded.role !== user.role
&& _ROLE_RANK[decoded.role] > (_ROLE_RANK[user.role] ?? 0)) {
&& _ROLE_RANK[decoded.role] > (_ROLE_RANK[user.role] ?? 0)) {
updates.push('role = ?'); params.push(decoded.role);
updates.push('role = ?'); params.push(decoded.role);
@@ -661,12 +661,12 @@ function sanitizeUser(user, publicOnly = false) {
@@ -661,12 +661,12 @@ function sanitizeUser(user, publicOnly = false) {
capabilities: permissions.getCapabilities(user),
capabilities: permissions.getCapabilities(user),
};
};
if (!publicOnly) {
if (!publicOnly) {
- // Balances are real money (Vibes) and the user's own business, like email and the key.
+ // Balances are real money (Vibes) and the user's own business, like the key. No email: the OpenVibe
+ // account keeps it (openvibe.network/my) and Live does not read users.email (WS-B task 2).
// Under BILLING_AUTHORITY=billing the column is a frozen legacy copy (ADR-012 rule 8: never
// Under BILLING_AUTHORITY=billing the column is a frozen legacy copy (ADR-012 rule 8: never
// shown as live); the balance comes from /api/funds/balance.
// shown as live); the balance comes from /api/funds/balance.
if (!require('../monetization/money-authority').onBilling()) safe.openvibe_bucks_balance = user.openvibe_bucks_balance;
if (!require('../monetization/money-authority').onBilling()) safe.openvibe_bucks_balance = user.openvibe_bucks_balance;
safe.openvibe_coins_balance = user.openvibe_coins_balance;
safe.openvibe_coins_balance = user.openvibe_coins_balance;
- safe.email = user.email;
safe.stream_key = user.stream_key;
safe.stream_key = user.stream_key;
}
}
return safe;
return safe;
@@ -7,16 +7,27 @@
@@ -7,16 +7,27 @@
* 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).
*
*
+ * Which Live account is the person's: linked_accounts (service 'network'), by subject_id, else by the Network
+ * user id in service_user_id (localUser below). An account with no such row has no Network identity (a legacy
+ * local account, kept for a future claim flow).
+ *
* The linked Live account follows the projection in the same step: picture, colour, display name and
* 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:
* 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
* - 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;
+ * both ways (a downgrade too, which the token sync in auth.js never does; this is the only path for one
+ * since the key-only /internal/user-role push was retired), 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;
* - 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
* - 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.
+ *
+ * users.role is therefore this projection's output: Network's staff roles (the staff map) applied by the
+ * rules above, plus Live's own `streamer` (and a grant from Live's admin panel, until Network next changes
+ * that person's role). Live code reads users.role (through permissions.js) locally, which is reading the
+ * projection; it stays a column. Identity itself stays on the OpenVibe account: Live neither stores nor
+ * reads users.email or users.password_hash (test/identity-columns.test.js), and the operator script
+ * scripts/identity-columns-contract.js clears the legacy values (WS-B task 2 step 4).
*/
*/
const db = require('../db/database');
const db = require('../db/database');
@@ -105,6 +105,42 @@ const MIGRATIONS = [
@@ -105,6 +105,42 @@ const MIGRATIONS = [
},
},
];
];
+/**
+ * SQL for the ids of Live accounts linked to a Network subject: a linked_accounts row (service 'network') that
+ * names the subject, or whose Network user id subject_projection knows (server/auth/subject-projection.js
+ * resolves a person the same way). An account without one has no Network identity.
+ */
+function networkLinkedUserIds(db) {
+ const viaProjection = tableExists(db, 'subject_projection')
+ ? ' OR EXISTS (SELECT 1 FROM subject_projection sp WHERE sp.network_user_id = CAST(la.service_user_id AS INTEGER))'
+ : '';
+ return `SELECT la.user_id FROM linked_accounts la WHERE la.service = 'network' AND (substr(COALESCE(la.subject_id, ''), 1, 4) = 'usr_'${viaProjection})`;
+}
+
+/**
+ * Operator migrations: contract steps (expand/migrate/contract, ADR-028) that clear data the release before
+ * might still want, so they never run at boot: run() from initDb() walks MIGRATIONS only. An operator script
+ * runs one with runOperator() after taking its own backup; the ledger and the transaction are the same, so it
+ * runs at most once and is recorded like any other. Append only, like MIGRATIONS; ids start with `op_`.
+ */
+const OPERATOR_MIGRATIONS = [
+ {
+ id: 'op_001_identity_columns_contract',
+ // WS-B task 2 step 4 (scripts/identity-columns-contract.js). The OpenVibe account keeps the email and the
+ // password; Live stopped writing them (createUser) and reading them (test/identity-columns.test.js). This
+ // clears what is left: every stored email, and every password value other than the SSO placeholder on an
+ // account linked to a Network subject (that person signs in through openvibe.network). Accounts with no
+ // Network identity keep their legacy hash for a future claim flow. The columns stay (no DROP COLUMN:
+ // SELECT * readers); NOT NULL password_hash gets the same '$sso$' + 64 hex placeholder as new accounts.
+ up: (db) => {
+ if (!tableExists(db, 'users') || !tableExists(db, 'linked_accounts')) return DEFER;
+ db.prepare('UPDATE users SET email = NULL WHERE email IS NOT NULL').run();
+ db.prepare(`UPDATE users SET password_hash = '$sso$' || lower(hex(randomblob(32)))
+ WHERE substr(password_hash, 1, 5) <> '$sso$' AND id IN (${networkLinkedUserIds(db)})`).run();
+ },
+ },
+];
+
const failures = new Map(); // id -> message, for this process
const failures = new Map(); // id -> message, for this process
function run(db, list = MIGRATIONS) {
function run(db, list = MIGRATIONS) {
@@ -144,4 +180,17 @@ function getStatus(db, list = MIGRATIONS) {
@@ -144,4 +180,17 @@ function getStatus(db, list = MIGRATIONS) {
return list.map((m) => rows.get(m.id) || { id: m.id, mode: failures.has(m.id) ? 'failed' : 'pending', error: failures.get(m.id) || null });
return list.map((m) => rows.get(m.id) || { id: m.id, mode: failures.has(m.id) ? 'failed' : 'pending', error: failures.get(m.id) || null });
}
}
-module.exports = { MIGRATIONS, DEFER, run, getStatus };
+/**
+ * Run one operator migration now (never called at boot). → { id, outcome: 'applied' | 'deferred' | 'failed'
+ * | 'already', error?, applied_at? }
+ */
+function runOperator(db, id, list = OPERATOR_MIGRATIONS) {
+ const m = list.find((x) => x.id === id);
+ if (!m) throw new Error(`no operator migration ${id}`);
+ const res = run(db, [m])[0];
+ if (res) return res;
+ const row = db.prepare('SELECT applied_at FROM schema_migrations WHERE id = ?').get(id);
+ return { id, outcome: 'already', applied_at: row ? row.applied_at : null };
+}
+
+module.exports = { MIGRATIONS, OPERATOR_MIGRATIONS, DEFER, run, getStatus, runOperator, networkLinkedUserIds };
@@ -72,10 +72,10 @@ router.post('/url-registry/refresh', async (req, res) => {
@@ -72,10 +72,10 @@ router.post('/url-registry/refresh', async (req, res) => {
}
}
});
});
-// Authoritative role push from openvibe.network (the SSO/role authority). Used so a
-// role change propagates to the local user record immediately — instead of
-// waiting on the user's next token (up to 24h) and without letting a stale token
-// downgrade them. Matches by openvibe.network account link first, then username.
+// Roles are no longer pushed here: POST /internal/user-role (key-only, register C-54/C-55) was retired in
+// WS-B task 2. A role change on the Network reaches Live as network.user.updated at POST /internal/network-events
+// (server/auth/subject-projection.js), which also applies downgrades.
+
// ── The account's avatar changed on the Network (or on another site) ─────────
// ── The account's avatar changed on the Network (or on another site) ─────────
// The avatar is a network-wide property (OpenVibe.Network server/profile/avatar.js). Live keeps a copy on its
// The avatar is a network-wide property (OpenVibe.Network server/profile/avatar.js). Live keeps a copy on its
// own user row because every stream card, chat line and profile reads it locally.
// own user row because every stream card, chat line and profile reads it locally.
@@ -104,32 +104,4 @@ router.post('/user-avatar', (req, res) => {
@@ -104,32 +104,4 @@ router.post('/user-avatar', (req, res) => {
}
}
});
});
-router.post('/user-role', (req, res) => {
- try {
- const { username, openvibenetwork_id, role } = req.body || {};
- const VALID = ['user', 'streamer', 'global_mod', 'admin'];
- if (!VALID.includes(role)) return res.status(400).json({ ok: false, error: 'invalid role' });
-
- let user = null;
- if (openvibenetwork_id != null) {
- const linked = db.getDb().prepare(
- "SELECT user_id FROM linked_accounts WHERE service = 'network' AND service_user_id = ?"
- ).get(String(openvibenetwork_id));
- if (linked) user = db.getUserById(linked.user_id);
- }
- if (!user && username) user = db.getUserByUsername(username);
- if (!user) return res.status(404).json({ ok: false, error: 'user not found' });
-
- // Never strip the owner's admin role via a role push (is_owner is local).
- const finalRole = (user.is_owner && role !== 'admin') ? 'admin' : role;
- db.getDb().prepare('UPDATE users SET role = ? WHERE id = ?').run(finalRole, user.id);
- notifyChat(user.id);
- console.log(`[Internal] role push: ${user.username} -> ${finalRole}`);
- return res.json({ ok: true, id: user.id, username: user.username, role: finalRole });
- } catch (err) {
- console.error('[Internal] user-role error:', err.message);
- return res.status(500).json({ ok: false, error: err.message });
- }
-});
-
module.exports = router;
module.exports = router;
@@ -320,7 +320,7 @@ router.post('/cashout/:id/deny', requireOwner, money.guardWrite, (req, res) => {
@@ -320,7 +320,7 @@ router.post('/cashout/:id/deny', requireOwner, money.guardWrite, (req, res) => {
router.get('/cashouts/pending', requireOwner, (req, res) => {
router.get('/cashouts/pending', requireOwner, (req, res) => {
if (money.onBilling()) return cashoutsInBilling(res);
if (money.onBilling()) return cashoutsInBilling(res);
const pending = db.all(`
const pending = db.all(`
- SELECT t.*, u.username, u.display_name, u.email
+ SELECT t.*, u.username, u.display_name
FROM transactions t
FROM transactions t
JOIN users u ON t.from_user_id = u.id
JOIN users u ON t.from_user_id = u.id
WHERE t.type = 'cashout' AND t.status = 'escrow'
WHERE t.type = 'cashout' AND t.status = 'escrow'
@@ -1,6 +1,9 @@
@@ -1,6 +1,9 @@
'use strict';
'use strict';
// WS-B task 2: Live keeps no passwords and no email addresses (the OpenVibe account keeps them). The code
// 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.
+// never hashes a password, never writes or reads users.email or users.password_hash (the frozen-column
+// tripwire), and createUser refuses a real hash or an address. The one exception is the contract step,
+// scripts/identity-columns-contract.js (operator migration op_001_identity_columns_contract): it clears the
+// legacy values, only when an operator runs it, after a backup, and never at boot.
const assert = require('assert');
const assert = require('assert');
const fs = require('fs');
const fs = require('fs');
const os = require('os');
const os = require('os');
@@ -17,19 +20,133 @@ console.log = quiet;
@@ -17,19 +20,133 @@ console.log = quiet;
const files = [];
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); } };
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'));
walk(path.join(__dirname, '..', 'server'));
+const MIGRATIONS = path.join('server', 'db', 'migrations.js');
+// The contract step's own writes (and nothing else in that file).
+const CONTRACT_WRITES = ['UPDATE users SET email = NULL', "UPDATE users SET password_hash = '$sso$'"];
+// Reads of the legacy columns: a users alias or a user object's field, or SQL selecting or filtering them on users.
+// camera_profiles.password_hash (ONVIF device credentials, server/controls) is another table: `camera.password_hash`.
+const READS = [
+ /\bu\.(email|password_hash)\b/,
+ /\b\w*[uU]ser\.(email|password_hash)\b/,
+ /SELECT\b[^;`'"]*\b(email|password_hash)\b[^;`'"]*\bFROM\s+users\b/i,
+ /\bFROM\s+users\b[^;`'"]*\b(email|password_hash)\b/i,
+];
const offenders = [];
const offenders = [];
for (const f of files) {
for (const f of files) {
+ const rel = path.relative(path.join(__dirname, '..'), f);
const src = fs.readFileSync(f, 'utf8');
const src = fs.readFileSync(f, 'utf8');
// server/controls hashes robot and camera control secrets (devices, not accounts): allowed.
// 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`);
+ if (!f.includes(`${path.sep}controls${path.sep}`) && /require\(['"]bcrypt(js)?['"]\)/.test(src)) offenders.push(`${rel}: requires bcrypt`);
+ for (const m of src.matchAll(/UPDATE\s+users\s+SET[^;`'"]*\b(email|password_hash)\s*=\s*('\$sso\$'|NULL)?/gi)) {
+ const stmt = m[0].replace(/\s+/g, ' ');
+ if (!(rel === MIGRATIONS && CONTRACT_WRITES.includes(stmt))) offenders.push(`${rel}: writes users.${m[1]} (${stmt})`);
+ }
+ for (const re of READS) { const m = src.match(re); if (m) offenders.push(`${rel}: reads users.${m[1]} (${m[0].replace(/\s+/g, ' ').slice(0, 80)})`); }
}
}
-assert.deepStrictEqual(offenders, [], 'Live never hashes passwords or writes users.email');
+assert.deepStrictEqual(offenders, [], 'Live never hashes passwords or writes or reads users.email / users.password_hash');
const base = { username: 'x1', display_name: 'X', stream_key: 'k1' };
const base = { username: 'x1', display_name: 'X', stream_key: 'k1' };
assert.throws(() => db.createUser({ ...base, password_hash: '$2a$10$abcdefghijklmnopqrstuv' }), /stores no passwords/);
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;
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.ok(id, 'an SSO account is created');
assert.strictEqual(db.getUserById(id).email, null, 'an email address given to createUser is not stored');
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');
+
+// ── The contract step (scripts/identity-columns-contract.js) ─────────────────────
+const raw = db.getDb();
+const migrations = require('../server/db/migrations');
+const contract = require('../scripts/identity-columns-contract');
+const BCRYPT = '$2a$10$' + 'x'.repeat(53); // bcrypt-shaped, obviously fake
+const SSO_KEPT = '$sso$' + 'ab'.repeat(32);
+const addUser = (name, email, hash) => Number(raw.prepare('INSERT INTO users (username, display_name, email, password_hash, stream_key) VALUES (?, ?, ?, ?, ?)')
+ .run(name, name, email, hash, `sk-${name}`).lastInsertRowid);
+const link = (userId, networkId, subject) => raw.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, service_username, subject_id) VALUES (?, 'network', ?, 'x', ?)")
+ .run(userId, String(networkId), subject);
+const linkedSub = addUser('linked_sub', '[email protected]', BCRYPT); // linked, the subject on the link
+link(linkedSub, 101, 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA');
+const linkedProj = addUser('linked_proj', '[email protected]', BCRYPT); // linked, the subject only in the projection
+link(linkedProj, 102, null);
+require('../server/auth/subject-projection').ensureSchema();
+raw.prepare("INSERT INTO subject_projection (subject_id, network_user_id, revision, username, role) VALUES ('usr_01JAB2C3D4E5F6G7H8J9K0MNPB', 102, 1, 'linked_proj', 'user')").run();
+const linkedNoSub = addUser('linked_nosub', null, BCRYPT); // a link that names no known subject: kept
+link(linkedNoSub, 103, null);
+const legacy = addUser('legacy_local', '[email protected]', BCRYPT); // no Network identity: kept
+const anonGame = addUser('anon_game', null, '!anon-game:k:0123'); // a guest placeholder: kept
+const ssoAlready = addUser('sso_already', '[email protected]', SSO_KEPT); // already the placeholder: untouched
+link(ssoAlready, 104, 'usr_01JAB2C3D4E5F6G7H8J9K0MNPC');
+const row = (uid) => raw.prepare('SELECT email, password_hash FROM users WHERE id = ?').get(uid);
+const snapshot = () => raw.prepare('SELECT id, email, password_hash FROM users ORDER BY id').all();
+
+// Boot never runs it.
+const before = snapshot();
+console.log = () => {};
+db.initDb(); migrations.run(raw);
+console.log = quiet;
+assert.deepStrictEqual(snapshot(), before, 'boot migrations leave the identity columns alone');
+assert.ok(!raw.prepare('SELECT 1 FROM schema_migrations WHERE id = ?').get(contract.ID), 'and do not record the contract step');
+assert.ok(!migrations.MIGRATIONS.some((m) => m.id === contract.ID) && migrations.OPERATOR_MIGRATIONS.some((m) => m.id === contract.ID));
+
+const secrets = ['[email protected]', '[email protected]', '[email protected]', '[email protected]', BCRYPT, SSO_KEPT, 'linked_sub', 'legacy_local'];
+const run = async (argv) => {
+ const lines = [];
+ console.log = () => {}; // the migration runner's own [DB] line
+ try { return { code: await contract.main(argv, (l) => lines.push(String(l))), out: lines.join('\n') }; } finally { console.log = quiet; }
+};
+const noValues = (out) => { for (const s of secrets) assert.ok(!out.includes(s), 'the script prints counts only'); };
+
+(async () => {
+ const dbArg = ['--db', process.env.DB_PATH];
+ // Dry run (the default): counts, nothing changed.
+ let r = await run(dbArg);
+ assert.strictEqual(r.code, 0);
+ noValues(r.out);
+ assert.match(r.out, /not run yet/);
+ assert.match(r.out, /emails stored: 4 -> set to NULL/);
+ assert.match(r.out, /other than the SSO placeholder: 5\b/);
+ assert.match(r.out, /linked to a Network subject: 2 -> replaced/);
+ assert.match(r.out, /Network link but no known subject: 1 -> kept/);
+ assert.match(r.out, /no Network identity: 2 -> kept \(password hashes 1, anon-game placeholders 1, other 0\)/);
+ assert.match(r.out, /dry run: nothing changed/);
+ assert.deepStrictEqual(snapshot(), before, 'a dry run changes nothing');
+ assert.deepStrictEqual(contract.parseArgs(['--dry-run']).apply, false);
+
+ // Apply: backup first (0600, still holding the old values), then the change, recorded once.
+ const backup = path.join(tmp, 'backups', 'pre.db');
+ r = await run([...dbArg, '--apply', '--backup', backup]);
+ assert.strictEqual(r.code, 0, r.out);
+ noValues(r.out);
+ assert.ok(fs.existsSync(backup), 'a backup is taken');
+ assert.strictEqual(fs.statSync(backup).mode & 0o777, 0o600);
+ const Database = require('better-sqlite3');
+ const b = new Database(backup, { readonly: true });
+ assert.strictEqual(b.prepare('SELECT COUNT(*) AS n FROM users WHERE email IS NOT NULL').get().n, 4, 'the backup has the old values');
+ b.close();
+
+ assert.strictEqual(raw.prepare('SELECT COUNT(*) AS n FROM users WHERE email IS NOT NULL').get().n, 0, 'every email is cleared');
+ for (const uid of [linkedSub, linkedProj]) {
+ assert.match(row(uid).password_hash, /^\$sso\$[0-9a-f]{64}$/, 'a linked account gets the SSO placeholder');
+ }
+ assert.notStrictEqual(row(linkedSub).password_hash, row(linkedProj).password_hash, 'each placeholder is random');
+ assert.strictEqual(row(linkedNoSub).password_hash, BCRYPT, 'a link without a known subject keeps its value');
+ assert.strictEqual(row(legacy).password_hash, BCRYPT, 'an account with no Network identity keeps its hash');
+ assert.strictEqual(row(anonGame).password_hash, '!anon-game:k:0123');
+ assert.strictEqual(row(ssoAlready).password_hash, SSO_KEPT, 'a placeholder is left as it was');
+ assert.strictEqual(raw.prepare('SELECT mode FROM schema_migrations WHERE id = ?').get(contract.ID).mode, 'applied');
+ assert.match(r.out, /now:[\s\S]*emails stored: 0[\s\S]*linked to a Network subject: 0/);
+
+ // Once only.
+ const after = snapshot();
+ r = await run([...dbArg, '--apply', '--backup', path.join(tmp, 'backups', 'again.db')]);
+ assert.strictEqual(r.code, 0);
+ assert.match(r.out, /already applied: nothing to do/);
+ assert.ok(!fs.existsSync(path.join(tmp, 'backups', 'again.db')), 'nothing to do, no backup');
+ assert.deepStrictEqual(snapshot(), after);
+ assert.strictEqual(migrations.runOperator(raw, contract.ID).outcome, 'already');
+ assert.throws(() => migrations.runOperator(raw, 'op_999_nope'), /no operator migration/);
+
+ // An existing backup file is never overwritten.
+ raw.prepare("DELETE FROM schema_migrations WHERE id = ?").run(contract.ID);
+ await assert.rejects(run([...dbArg, '--apply', '--backup', backup]), /already exists/);
+
+ fs.rmSync(tmp, { recursive: true, force: true });
+ console.log('identity columns: all checks passed');
+})().catch((e) => { console.error(e); process.exit(1); });