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

OP OpenVibers committed 26 Sept 2026 · 3a99cdc308 · 10 files, +352 −54

Reads: the admin user list no longer selects or searches email (staff find people by username or display name; the address is the OpenVibe account's), PUT /api/admin/users/:id no longer returns it (nor pushes it to the person's chat sockets), /api/auth/me drops it, and the pending-cashouts list drops u.email (the PayPal address is on the transaction). users.role stays a column: it is subject-projection.js's output (Network's staff roles by the event rules, plus Live's own streamer), so reading it locally is reading the projection; the module header and docs/architecture.md say so.

Retired: POST /internal/user-role (key-only, register C-54/C-55). Network's role changes reach Live as network.user.updated at POST /internal/network-events, which applies downgrades too; comments in auth.js and subject-projection.js updated.

Contract step (not run by this commit): server/db/migrations.js gains OPERATOR_MIGRATIONS, never run at boot, and runOperator() (same ledger and transaction). op_001_identity_columns_contract sets users.email to NULL on every row and gives every account linked to a Network subject (linked_accounts service 'network' naming a usr_ subject, or whose Network user id subject_projection knows) the '$sso$' + 64 hex placeholder in place of any other password value. Accounts with no Network identity keep their legacy hash for a future claim flow; no column is dropped. scripts/identity-columns-contract.js runs it: a dry run by default (counts only, never a value), --apply takes an online backup first (default <db dir>/backups/live-pre-identity-contract-<time>.db, mode 0600).

test/identity-columns.test.js is now the frozen-column tripwire for reads too (u.email, user.email, SELECT/FROM users naming email or password_hash), allows only the contract step's two writes, and runs the script on a temp database: boot leaves the columns alone, the dry run changes nothing and prints no value, --apply backs up then clears exactly the linked accounts' hashes and every email, and a second run does nothing

Changes

ddiff --git a/docs/architecture.md b/docs/architecture.md
iindex 323c88a7..a57b7047 100644
---- a/docs/architecture.md
++++ b/docs/architecture.md
@@@ -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.
- 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`).
++- 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
ddiff --git a/scripts/identity-columns-contract.js b/scripts/identity-columns-contract.js
nnew file mode 100644
iindex 00000000..9fbb5ea8
---- /dev/null
++++ b/scripts/identity-columns-contract.js
@@@ -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 };
ddiff --git a/server/admin/routes.js b/server/admin/routes.js
iindex 34598a8b..826e9a6f 100644
---- a/server/admin/routes.js
++++ b/server/admin/routes.js
@@@ -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_disabled
FROM 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 connections
if (updates.length > 0) {
ddiff --git a/server/auth/auth.js b/server/auth/auth.js
iindex a4686ca9..b59618b7 100644
---- a/server/auth/auth.js
++++ b/server/auth/auth.js
@@@ -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);
ddiff --git a/server/auth/routes.js b/server/auth/routes.js
iindex ac0d569b..36d02e3c 100644
---- a/server/auth/routes.js
++++ b/server/auth/routes.js
@@@ -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;
ddiff --git a/server/auth/subject-projection.js b/server/auth/subject-projection.js
iindex 6139f6fb..2b8c7e23 100644
---- a/server/auth/subject-projection.js
++++ b/server/auth/subject-projection.js
@@@ -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');
ddiff --git a/server/db/migrations.js b/server/db/migrations.js
iindex 75219ef2..580f3f67 100644
---- a/server/db/migrations.js
++++ b/server/db/migrations.js
@@@ -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
function 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 };
ddiff --git a/server/internal/routes.js b/server/internal/routes.js
iindex 3ef11d33..114f5d6a 100644
---- a/server/internal/routes.js
++++ b/server/internal/routes.js
@@@ -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;
ddiff --git a/server/monetization/routes.js b/server/monetization/routes.js
iindex f59c8b36..b332be23 100644
---- a/server/monetization/routes.js
++++ b/server/monetization/routes.js
@@@ -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_name
FROM transactions t
JOIN users u ON t.from_user_id = u.id
WHERE t.type = 'cashout' AND t.status = 'escrow'
ddiff --git a/test/identity-columns.test.js b/test/identity-columns.test.js
iindex bd224efc..758328da 100644
---- a/test/identity-columns.test.js
++++ b/test/identity-columns.test.js
@@@ -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); });