openvibers/openvibe.live Commits
Leave test accounts out of Live's public people surfaces (#830)
Changes
nnew file mode 100644@@@ -0,0 +1,7 @@++-- phase: expand++-- Test accounts (probe and older manual test accounts, OpenVibe.Network users.is_test). Live keeps the flag on the++-- local user row so its public people surfaces (Recently Online, the directory, the home digest and the people++-- counts) can leave them out in SQL. The projection cache keeps it too, so the sync can follow linked accounts by++-- subject. Additive: an older release never reads either column.++ALTER TABLE users ADD COLUMN IF NOT EXISTS is_test bigint NOT NULL DEFAULT 0;++ALTER TABLE subject_projection ADD COLUMN IF NOT EXISTS is_test bigint NOT NULL DEFAULT 0;@@@ -113,6 +113,9 @@ async function _syncSsoUserFields(user, decoded) {&& _ROLE_RANK[decoded.role] > (_ROLE_RANK[user.role] ?? 0)) {updates.push('role = ?'); params.push(decoded.role);}++ // The probe tooling's sign-up prefix (OpenVibe.Network /api/auth/register marks the same names is_test): a probe++ // that signs in is marked at once, before the hourly identity list reaches this account.++ if (/^ovprobe_/i.test(String(decoded.username || '')) && !Number(user.is_test)) updates.push('is_test = 1');if (!updates.length) return user;try {params.push(user.id);@@@ -169,6 +172,8 @@ async function resolveNetworkUser(decoded) {if (decoded.role && ['user', 'streamer', 'global_mod', 'admin'].includes(decoded.role)) {updates.push('role = ?'); params.push(decoded.role);}++ // A probe account is marked when it is created here too (server/auth/test-accounts.js keeps the list).++ if (/^ovprobe_/i.test(String(decoded.username || ''))) updates.push('is_test = 1');if (updates.length > 0) {params.push(user.id);await db.getDb().prepare(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`).run(...params);nnew file mode 100644@@@ -0,0 +1,75 @@++'use strict';++/**++ * Test accounts on Live (OpenVibe.Network users.is_test).++ *++ * The probes sign in as real accounts, and Live's public people surfaces must never show them: Recently Online++ * (the directory), the home digest's new-members faces and counts, and the hero's people counts. The identity++ * authority lists its test accounts at GET /internal/identity/test-subjects (identity.subject.resolve); this module++ * writes them into subject_projection with is_test = 1 and follows linked_accounts to the local users row, so the++ * public queries filter `users.is_test` in SQL and never call Network while a page is read.++ *++ * A refresh replaces the whole flag set in one transaction: a subject the Network unmarks becomes ordinary again,++ * and a failed refresh (Network down, no client secret) changes nothing, so the last good list keeps test accounts++ * hidden. The account's own signed-in views are untouched: they read their own rows, not the public lists.++ */++const db = require('../db/database');++const principal = require('../net/network-principal');++const { OV_NETWORK_INTERNAL_URL } = require('../utils/notify');++++const REFRESH_MS = 60 * 60 * 1000;++const TIMEOUT_MS = 8000;++const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++let timer = null;++let last = { at: null, count: 0, error: null };++++/** One full sync. Throws with a short, safe reason; never logs a token. */++async function refresh({ fetchImpl = globalThis.fetch } = {}) {++ const headers = await principal.headersFor('/internal/identity/test-subjects');++ const res = await fetchImpl(`${OV_NETWORK_INTERNAL_URL}/internal/identity/test-subjects`, {++ headers: { Accept: 'application/json', ...headers }, signal: AbortSignal.timeout(TIMEOUT_MS),++ });++ if (res.status === 401) principal.tokenRejected('401');++ const body = await res.json().catch(() => null);++ if (!res.ok || !body || !Array.isArray(body.subjects)) {++ throw new Error(`Network ${res.status}${body && (body.code || body.error) ? `: ${body.code || body.error}` : ''}`);++ }++ const rows = body.subjects.filter((p) => p && p.subject && SUBJECT_RE.test(String(p.subject.id)))++ .map((p) => ({ subject: p.subject.id, network_user_id: Number.isInteger(p.network_user_id) ? p.network_user_id : null,++ username: typeof p.username === 'string' ? p.username.slice(0, 64) : null }));++ const d = db.getDb();++ await d.tx(async () => {++ await d.prepare('UPDATE users SET is_test = 0 WHERE is_test <> 0').run();++ await d.prepare('UPDATE subject_projection SET is_test = 0 WHERE is_test <> 0').run();++ for (const r of rows) {++ // A projection the events may not have sent yet: the row is enough for the link, revision 0 never beats an event.++ await d.prepare(`INSERT INTO subject_projection (subject_id, network_user_id, revision, username, role, is_test)++ VALUES (?, ?, 0, ?, 'user', 1)++ ON CONFLICT (subject_id) DO UPDATE SET is_test = 1,++ network_user_id = COALESCE(excluded.network_user_id, subject_projection.network_user_id)`)++ .run(r.subject, r.network_user_id, r.username);++ if (r.username) await d.prepare('UPDATE users SET is_test = 1 WHERE lower(username) = lower(?)').run(r.username);++ }++ // The subject link is the authority when a local name and the Network's current name differ (a rename).++ await d.prepare(`UPDATE users SET is_test = 1 WHERE id IN (++ SELECT la.user_id FROM linked_accounts la JOIN subject_projection sp ON sp.subject_id = la.subject_id++ WHERE sp.is_test = 1)`).run();++ });++ last = { at: new Date().toISOString(), count: rows.length, error: null };++ if (rows.length) console.log(`[Test accounts] ${rows.length} marked (users.is_test)`);++ return rows.length;++}++++/** Refresh now (unless the caller already awaited one), then hourly; a failure leaves the last good flags. */++function start({ refreshNow = true } = {}) {++ if (timer) return timer;++ if (refreshNow) refresh().catch((err) => { last.error = err.message; console.warn('[Test accounts] refresh failed:', err.message); });++ timer = setInterval(() => {++ refresh().catch((err) => { last.error = err.message; console.warn('[Test accounts] refresh failed:', err.message); });++ }, REFRESH_MS);++ if (timer.unref) timer.unref();++ return timer;++}++++function stop() { if (timer) clearInterval(timer); timer = null; }++++module.exports = { refresh, start, stop, status: () => ({ ...last }), REFRESH_MS };@@@ -1428,7 +1428,7 @@ async function getRecentlyOnlineStreamers(limit = 20, offset = 0) {FROM streams sJOIN users u ON s.user_id = u.idLEFT JOIN streamer_overviews o ON o.user_id = u.id-- WHERE (s.is_live = 0 AND s.ended_at IS NOT NULL) OR s.is_live = 1++ WHERE ((s.is_live = 0 AND s.ended_at IS NOT NULL) OR s.is_live = 1) AND COALESCE(u.is_test, 0) = 0GROUP BY u.id, o.overview, o.overview_shortORDER BY last_online_at DESCLIMIT ? OFFSET ?@@@ -1437,9 +1437,9 @@ async function getRecentlyOnlineStreamers(limit = 20, offset = 0) {async function countRecentlyOnlineStreamers() {return (await get(`-- SELECT COUNT(DISTINCT user_id) AS count-- FROM streams-- WHERE (is_live = 0 AND ended_at IS NOT NULL) OR is_live = 1++ SELECT COUNT(DISTINCT s.user_id) AS count++ FROM streams s JOIN users u ON u.id = s.user_id++ WHERE ((s.is_live = 0 AND s.ended_at IS NOT NULL) OR s.is_live = 1) AND COALESCE(u.is_test, 0) = 0`))?.count || 0;}@@@ -1775,14 +1775,14 @@ async function _computeHomeStats() {vods: null,clips: null,liveSessions: await c(`SELECT COUNT(*) AS count FROM streams`),-- streamers: await c(`SELECT COUNT(DISTINCT user_id) AS count FROM streams WHERE user_id IS NOT NULL`),++ streamers: await c(`SELECT COUNT(DISTINCT s.user_id) AS count FROM streams s JOIN users u ON u.id = s.user_id WHERE COALESCE(u.is_test, 0) = 0`),// Distinct people who went live in the last seven days, and the seven before.-- streamedWeek: await c(`SELECT COUNT(DISTINCT user_id) AS count FROM streams WHERE user_id IS NOT NULL AND created_at >= datetime('now', '-7 days')`),-- prevStreamedWeek: await c(`SELECT COUNT(DISTINCT user_id) AS count FROM streams WHERE user_id IS NOT NULL AND created_at >= datetime('now', '-14 days') AND created_at < datetime('now', '-7 days')`),++ streamedWeek: await c(`SELECT COUNT(DISTINCT s.user_id) AS count FROM streams s JOIN users u ON u.id = s.user_id WHERE COALESCE(u.is_test, 0) = 0 AND s.created_at >= datetime('now', '-7 days')`),++ prevStreamedWeek: await c(`SELECT COUNT(DISTINCT s.user_id) AS count FROM streams s JOIN users u ON u.id = s.user_id WHERE COALESCE(u.is_test, 0) = 0 AND s.created_at >= datetime('now', '-14 days') AND s.created_at < datetime('now', '-7 days')`),// OpenVibe.Chat owns chat_messages (roadmap T3): the total comes from Chat's read API.// A Chat outage answers the cache, else null — never a 500 for the home page.chatMessages: await (async () => { try { const s = await require('../chat/chat-reads').siteStatsPeek(); return s && s.messages != null ? s.messages : null; } catch { return null; } })(),-- users: await c(`SELECT COUNT(*) AS count FROM users WHERE COALESCE(is_banned, 0) = 0`),++ users: await c(`SELECT COUNT(*) AS count FROM users WHERE COALESCE(is_banned, 0) = 0 AND COALESCE(is_test, 0) = 0`),anons: await c(`SELECT COUNT(*) AS count FROM anon_ip_mappings`),follows: await c(`SELECT COUNT(*) AS count FROM follows`),// No platform-wide emote total: OpenVibe.Chat owns `emotes` (roadmap T3) and exposes counts@@@ -564,7 +564,7 @@ router.get('/digest', optionalAuth, async (req, res) => {const uid = req.user ? req.user.id : null;const followed = new Set(uid ? (await db.all('SELECT streamer_id FROM follows WHERE follower_id = ?', [uid]) || []).map(r => r.streamer_id) : []);const liveNow = (await db.all(`SELECT u.id AS user_id, u.username, u.display_name, u.avatar_url, u.profile_color, s.title, s.viewer_count, s.started_at-- FROM streams s JOIN users u ON u.id = s.user_id WHERE s.is_live = 1 AND COALESCE(u.is_banned, 0) = 0++ FROM streams s JOIN users u ON u.id = s.user_id WHERE s.is_live = 1 AND COALESCE(u.is_banned, 0) = 0 AND COALESCE(u.is_test, 0) = 0AND NOT (s.content_labels->'labels' @> '["18+"]'::jsonb)ORDER BY s.viewer_count DESC, s.started_at DESC LIMIT 10`) || []).map(r => ({ ...r, followed: followed.has(r.user_id) }));const liveIds = new Set(liveNow.map(r => r.user_id));@@@ -574,7 +574,7 @@ router.get('/digest', optionalAuth, async (req, res) => {(SELECT title FROM streams t WHERE t.user_id = u.id AND t.started_at >= ? ORDER BY t.started_at DESC LIMIT 1) AS last_title,(SELECT id FROM streams t WHERE t.user_id = u.id AND t.started_at >= ? ORDER BY t.started_at DESC LIMIT 1) AS last_stream_idFROM streams s JOIN users u ON u.id = s.user_id-- WHERE s.started_at >= ? AND s.is_live = 0 AND COALESCE(u.is_banned, 0) = 0++ WHERE s.started_at >= ? AND s.is_live = 0 AND COALESCE(u.is_banned, 0) = 0 AND COALESCE(u.is_test, 0) = 0GROUP BY u.id ORDER BY hours DESC, last_at DESC LIMIT 14`, [sinceSql, sinceSql, sinceSql]) || []).filter(r => !liveIds.has(r.user_id));const streamed = [];@@@ -601,10 +601,10 @@ router.get('/digest', optionalAuth, async (req, res) => {// rollback); a synchronous peek answers the last good count while Chat refreshes.chat_lines: await (async () => { try { const s = await require('../chat/chat-reads').windowStatsPeek({ since: sinceMs }); return s ? s.messages : 0; } catch { return 0; } })(),new_follows: Number((await one('SELECT COUNT(*) AS n FROM follows WHERE created_at >= ?', [sinceSql]) || {}).n || 0),-- new_members: Number((await one('SELECT COUNT(*) AS n FROM users WHERE created_at >= ? AND COALESCE(is_banned, 0) = 0 AND deleted_at IS NULL', [sinceSql]) || {}).n || 0),++ new_members: Number((await one('SELECT COUNT(*) AS n FROM users WHERE created_at >= ? AND COALESCE(is_banned, 0) = 0 AND COALESCE(is_test, 0) = 0 AND deleted_at IS NULL', [sinceSql]) || {}).n || 0),takes: Number((await one(`SELECT COUNT(*) AS n FROM rundown_segments s JOIN users u ON u.id = s.user_id${rundownRead.AUDIENCE_JOINS('s')} WHERE s.status = 'live' AND s.said_at >= @since-- AND COALESCE(u.is_banned, 0) = 0 AND ${rundownRead.VISIBLE('s')}`, { since: sinceSql, viewer: null }) || {}).n || 0),++ AND COALESCE(u.is_banned, 0) = 0 AND COALESCE(u.is_test, 0) = 0 AND ${rundownRead.VISIBLE('s')}`, { since: sinceSql, viewer: null }) || {}).n || 0),};const previous = new Date(now - 86400000).toISOString().replace('T', ' ').slice(0, 19);const prior = new Date(now - 2 * 86400000).toISOString().replace('T', ' ').slice(0, 19);@@@ -615,14 +615,14 @@ router.get('/digest', optionalAuth, async (req, res) => {};const publicTakes = async (start, end) => Number((await one(`SELECT COUNT(*) AS n FROM rundown_segments s JOIN users u ON u.id = s.user_id${rundownRead.AUDIENCE_JOINS('s')} WHERE s.status = 'live' AND s.said_at >= @start AND s.said_at < @end-- AND COALESCE(u.is_banned, 0) = 0 AND ${rundownRead.VISIBLE('s')}`, { start, end, viewer: null }) || {}).n || 0);++ AND COALESCE(u.is_banned, 0) = 0 AND COALESCE(u.is_test, 0) = 0 AND ${rundownRead.VISIBLE('s')}`, { start, end, viewer: null }) || {}).n || 0);const changes = {streams: await changeFor('streams', 'started_at'),takes: await publicTakes(previous, new Date(now).toISOString().replace('T', ' ').slice(0, 19)) - await publicTakes(prior, previous),new_follows: await changeFor('follows', 'created_at'),-- new_members: await changeFor('users', 'created_at', 'COALESCE(is_banned, 0) = 0 AND deleted_at IS NULL'),++ new_members: await changeFor('users', 'created_at', 'COALESCE(is_banned, 0) = 0 AND COALESCE(is_test, 0) = 0 AND deleted_at IS NULL'),};-- const newPeople = (await db.all('SELECT username, display_name, avatar_url FROM users WHERE created_at >= ? AND COALESCE(is_banned, 0) = 0 AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 3', [sinceSql]) || []);++ const newPeople = (await db.all('SELECT username, display_name, avatar_url FROM users WHERE created_at >= ? AND COALESCE(is_banned, 0) = 0 AND COALESCE(is_test, 0) = 0 AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 3', [sinceSql]) || []);// A take is shown only when the stream is in this visit's window, the topic is live, and Rundown allows everyone to see it.let topics = [];try {@@@ -632,14 +632,14 @@ router.get('/digest', optionalAuth, async (req, res) => {JOIN users u ON u.id = s.user_id JOIN rundown_topics t ON t.id = s.topic_id${AUDIENCE_JOINS('s')}WHERE s.status = 'live' AND t.status = 'live' AND s.said_at >= @since AND stream.started_at >= @since-- AND s.score >= 4 AND COALESCE(u.is_banned, 0) = 0 AND ${VISIBLE('s')}++ AND s.score >= 4 AND COALESCE(u.is_banned, 0) = 0 AND COALESCE(u.is_test, 0) = 0 AND ${VISIBLE('s')}ORDER BY s.score DESC, s.said_at DESC LIMIT 40`, { since: sinceSql, viewer: null });topics = selectTopics(rows);} catch { topics = []; }let nextStream = null;if (!stats.streams && !stats.takes && !stats.new_members && !stats.new_follows && !stats.chat_lines) {nextStream = await one(`SELECT a.title, a.starts_at, u.username, u.display_name FROM agent_stream_schedules a JOIN users u ON u.id = a.user_id-- WHERE a.state = 'scheduled' AND a.visibility = 'public' AND a.starts_at > ? AND COALESCE(u.is_banned, 0) = 0 ORDER BY a.starts_at LIMIT 1`, [new Date(now).toISOString()]);++ WHERE a.state = 'scheduled' AND a.visibility = 'public' AND a.starts_at > ? AND COALESCE(u.is_banned, 0) = 0 AND COALESCE(u.is_test, 0) = 0 ORDER BY a.starts_at LIMIT 1`, [new Date(now).toISOString()]);}const across = await acrossOpenVibe(sinceMs);const digest = { since, signed_in: !!uid, liveNow, streamed, stats, topics, newPeople, changes, nextStream, across };@@@ -1524,6 +1524,15 @@ async function start() {// 6f. VOD storage/offload is owned by OpenVibe.Media now — nothing to start here.++ // 6h. Test accounts (Network users.is_test): mark the local users the public people surfaces must leave out.++ // The first refresh is awaited before the listener opens, so the first public page already filters; a failure++ // keeps the last good flags and the hourly timer still runs.++ try {++ const testAccounts = require('./auth/test-accounts');++ await testAccounts.refresh().catch((err) => console.warn('[Test accounts] refresh failed:', err.message));++ testAccounts.start({ refreshNow: false });++ } catch (err) { console.warn('[Test accounts] not started:', err.message); }++// 7. Start HTTP server.//// Zero-downtime restarts: when systemd owns the listening socket (a .socket unit with@@@ -30,12 +30,15 @@ const take = async (uid, sid, { title, score, quote = null, status = 'live', wit[sid, uid, withTopic ? topic : null, title, `${title}.`, score, JSON.stringify(quote ? [{ text: quote, sec: 12, en: null }] : []), ago(150), status]);const ann = await mk('annstreams'), bob = await mk('bobstreams'), ban = await mk('bannedone', { banned: true });--const sa = await stream(ann), sb = await stream(bob), sx = await stream(ban);++await db.run("INSERT INTO users (username, email, password_hash, display_name, stream_key, is_test) VALUES ('ovprobe_digest', 'ovprobe@x', 'x', 'ovprobe_digest', ?, 1)", ['ovprobe-digest'.padEnd(32, '0')]);++const probe = (await db.get('SELECT id FROM users WHERE username = ?', ['ovprobe_digest'])).id;++const sa = await stream(ann), sb = await stream(bob), sx = await stream(ban), sp = await stream(probe);await take(ann, sa, { title: 'Autofill is a scam on support mains', score: 5, quote: 'autofill put me on support again chat' });await take(bob, sb, { title: 'Bob on his new keyboard', score: 4, withTopic: false });await take(bob, sb, { title: 'A quiet stretch', score: 3 });await take(ann, sa, { title: 'Taken down', score: 5, quote: 'never shown', status: 'hidden' });await take(ban, sx, { title: 'Banned streamer take', score: 5, quote: 'never shown either' });++await take(probe, sp, { title: 'Test account take', score: 5, quote: 'a probe must not be public' });const app = express();app.use('/api/home', require('../server/home/routes'));@@@ -51,6 +54,9 @@ try {assert.ok(d.html.includes(`/rundown/e/${sa}#rd-take-${d.topics[0].id}`));assert.ok(d.html.includes('What they talked about'));assert.ok(d.html.includes('New here'));++ assert.ok(!d.newPeople.some((p) => p.username === 'ovprobe_digest'), 'a test account is never a "New here" face');++ assert.ok(!d.streamed.some((p) => p.username === 'ovprobe_digest'), 'a test account is never a "Who streamed" row');++ assert.ok(!d.html.includes('ovprobe_digest') && !d.html.includes('Test account take'), 'the test account is nowhere in the digest');assert.ok(!d.html.includes('Bob on his new keyboard'));console.log('✅ the digest reads The Rundown');} finally {@@@ -22,9 +22,11 @@ const KEYS = ['streams_this_week', 'hours_this_week', 'usual_time', 'days_in_a_r(async () => {await db.initDb();const mk = async (username) => { await db.createUser({ username, email: `${username}@x`, password_hash: 'x', display_name: username, stream_key: username.padEnd(32, '0') }); const id = (await db.get('SELECT id FROM users WHERE username = ?', [username])).id; await db.ensureChannel(id); return id; };-- const pic = await mk('picasso'), nov = await mk('novod'), liv = await mk('liveone');++ const pic = await mk('picasso'), nov = await mk('novod'), liv = await mk('liveone'), probe = await mk('ovprobe_recently');const slot = async (uid, slug) => Number((await db.run('INSERT INTO managed_streams (user_id, slug, title, protocol, stream_key, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ov_now(), ov_now()) RETURNING id', [uid, slug, `${slug} title`, 'webrtc', `${slug}-key-${uid}`])).lastInsertRowid);-- const picSlot = await slot(pic, 'main'), livSlot = await slot(liv, 'main');++ const picSlot = await slot(pic, 'main'), livSlot = await slot(liv, 'main'), probeSlot = await slot(probe, 'main');++ // A test account (OpenVibe.Network users.is_test) with a stream must never be a public card.++ await db.run('UPDATE users SET is_test = 1 WHERE id = ?', [probe]);const stream = async (uid, ms, { live = false, startedAgo, endedAgo = null, thumb = null }) => await db.run(`INSERT INTO streams (user_id, managed_stream_id, title, protocol, is_live, viewer_count, started_at, ended_at, thumbnail_url, created_at)VALUES (?, ?, ?, 'webrtc', ?, 0, to_char(now() - make_interval(mins => ?), 'YYYY-MM-DD HH24:MI:SS'), ${live ? 'NULL' : "to_char(now() - make_interval(mins => ?), 'YYYY-MM-DD HH24:MI:SS')"}, ?, ov_now())`,@@@ -36,6 +38,7 @@ const KEYS = ['streams_this_week', 'hours_this_week', 'usual_time', 'days_in_a_rawait stream(nov, null, { startedAgo: 700, endedAgo: 650, thumb: '/api/thumbnails/stream-9103-3.jpg' });// A live stream: its capture still exists.await stream(liv, livSlot, { live: true, startedAgo: 10, thumb: '/api/thumbnails/stream-9104-live.jpg' });++ await stream(probe, probeSlot, { startedAgo: 400, endedAgo: 350, thumb: '/api/thumbnails/stream-9105-probe.jpg' });// A stored card line in the old shape (name first, colon, two sentences).await db.run("INSERT INTO streamer_overviews (user_id, overview, overview_short, model, sources, generated_at) VALUES (?, ?, ?, 'x', '{}', ov_now())",@@@ -63,6 +66,9 @@ const KEYS = ['streams_this_week', 'hours_this_week', 'usual_time', 'days_in_a_rconst by = (u) => body.streamers.find((s) => s.username === u);const p = by('picasso'), n = by('novod'), l = by('liveone');assert.ok(p && n && l, JSON.stringify(body.streamers.map((s) => s.username)));++ assert.strictEqual(by('ovprobe_recently'), undefined, 'a test account is never on Recently Online');++ assert.ok(!JSON.stringify(body).includes('ovprobe_recently'), 'the test account is nowhere in the public read');++ assert.strictEqual(body.streamers.length, 3, 'the probe is counted out too');// The replay rows picture the recording, not the deleted capture.assert.strictEqual(p.recent_facts.streams.length, 2);@@@ -360,6 +360,7 @@ async function check(name, fn) {'server/utils/notify.js': 'OpenVibe.Network (configured)','server/auth/identity-sync.js': 'OpenVibe.Network (configured)','server/auth/account-data.js': 'OpenVibe.Network (configured): export parts and deletion confirmations',++ 'server/auth/test-accounts.js': 'OpenVibe.Network (configured OV_NETWORK_INTERNAL_URL): the fixed /internal/identity/test-subjects path, no URL from a person','server/agent-streams/sharing.js': 'OpenVibe.Network (configured): the sharing system\'s name lookup and person resolution','server/rundown/citations.js': 'OpenVibe.Report public session-report list (OV_REPORT_URL, configured; fixed path, no URL from a person)','server/ai/viewer-quota.js': 'OpenVibe.AI (configured OV_AI_INTERNAL_URL): a streamer\'s daily AI-viewer cap',nnew file mode 100644@@@ -0,0 +1,61 @@++'use strict';++// Test accounts on Live (server/auth/test-accounts.js): the identity authority's is_test list becomes users.is_test,++// follows linked accounts by subject, and replaces the whole set on success; a failed refresh keeps the last good++// flags so a Network outage never makes a probe public again.++// node --import ./test/helpers/pg-preload.mjs test/test-accounts.test.js++const assert = require('assert');++++process.env.NODE_ENV = 'test';++console.warn = () => {};++const quiet = console.log;++console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };++++const db = require('../server/db/database');++const principal = require('../server/net/network-principal');++const testAccounts = require('../server/auth/test-accounts');++++const SUBJECT = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';++const OTHER = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR';++++(async () => {++ await db.initDb();++ const d = db.getDb();++ const user = await d.prepare("INSERT INTO users (username, password_hash, display_name) VALUES ('ovprobe_live', 'x', 'ovprobe_live') RETURNING id").get();++ const linked = await d.prepare("INSERT INTO users (username, password_hash, display_name) VALUES ('renamed_probe', 'x', 'renamed_probe') RETURNING id").get();++ await d.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (?, 'network', '410', ?)").run(linked.id, SUBJECT);++ await d.prepare("UPDATE users SET is_test = 0").run();++++ // The token is minted by the same principal the service uses; the Network answer is this stub.++ principal.headersFor = async () => ({ authorization: 'Bearer test-service-token' });++ let answer = { subjects: [{ subject: { type: 'user', id: SUBJECT }, network_user_id: 410, username: 'ovprobe_live', is_test: true }] };++ let down = false;++ const fetchImpl = async (url) => {++ if (down) return new Response('{}', { status: 503 });++ assert.ok(String(url).endsWith('/internal/identity/test-subjects'));++ return new Response(JSON.stringify(answer), { status: 200, headers: { 'content-type': 'application/json' } });++ };++++ assert.strictEqual(await testAccounts.refresh({ fetchImpl }), 1);++ assert.strictEqual(Number((await d.prepare('SELECT is_test FROM users WHERE id = ?').get(user.id)).is_test), 1, 'the probe name is marked');++ assert.strictEqual(Number((await d.prepare('SELECT is_test FROM users WHERE id = ?').get(linked.id)).is_test), 1, 'a renamed account follows its subject link');++ assert.strictEqual(Number((await d.prepare('SELECT is_test FROM subject_projection WHERE subject_id = ?').get(SUBJECT)).is_test), 1, 'the projection carries the flag');++++ // A failed refresh changes nothing.++ down = true;++ await assert.rejects(testAccounts.refresh({ fetchImpl }));++ assert.strictEqual(Number((await d.prepare('SELECT is_test FROM users WHERE id = ?').get(user.id)).is_test), 1, 'the last good list stays after an outage');++++ // A successful refresh with an empty list unmarks everyone (the Network no longer calls them test accounts).++ down = false; answer = { subjects: [] };++ assert.strictEqual(await testAccounts.refresh({ fetchImpl }), 0);++ assert.strictEqual(Number((await d.prepare('SELECT is_test FROM users WHERE id = ?').get(user.id)).is_test), 0);++ assert.strictEqual(Number((await d.prepare('SELECT is_test FROM users WHERE id = ?').get(linked.id)).is_test), 0);++ assert.strictEqual(Number((await d.prepare('SELECT is_test FROM subject_projection WHERE subject_id = ?').get(SUBJECT)).is_test), 0);++++ // A second subject is fine too and the module never trusts a malformed id.++ answer = { subjects: [{ subject: { type: 'user', id: OTHER }, network_user_id: 411, username: 'ovprobe_two' }, { subject: { type: 'user', id: 'nope' } }] };++ assert.strictEqual(await testAccounts.refresh({ fetchImpl }), 1);++++ console.log('test accounts: marked by name and by subject link, kept through an outage, unmarked by a new list');++ process.exit(0);++})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
+-- phase: expand
+-- Test accounts (probe and older manual test accounts, OpenVibe.Network users.is_test). Live keeps the flag on the
+-- local user row so its public people surfaces (Recently Online, the directory, the home digest and the people
+-- counts) can leave them out in SQL. The projection cache keeps it too, so the sync can follow linked accounts by
+-- subject. Additive: an older release never reads either column.
+ALTER TABLE users ADD COLUMN IF NOT EXISTS is_test bigint NOT NULL DEFAULT 0;
+ALTER TABLE subject_projection ADD COLUMN IF NOT EXISTS is_test bigint NOT NULL DEFAULT 0;
@@ -113,6 +113,9 @@ async function _syncSsoUserFields(user, decoded) {
@@ -113,6 +113,9 @@ async function _syncSsoUserFields(user, decoded) {
&& _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);
}
}
+ // The probe tooling's sign-up prefix (OpenVibe.Network /api/auth/register marks the same names is_test): a probe
+ // that signs in is marked at once, before the hourly identity list reaches this account.
+ if (/^ovprobe_/i.test(String(decoded.username || '')) && !Number(user.is_test)) updates.push('is_test = 1');
if (!updates.length) return user;
if (!updates.length) return user;
try {
try {
params.push(user.id);
params.push(user.id);
@@ -169,6 +172,8 @@ async function resolveNetworkUser(decoded) {
@@ -169,6 +172,8 @@ async function resolveNetworkUser(decoded) {
if (decoded.role && ['user', 'streamer', 'global_mod', 'admin'].includes(decoded.role)) {
if (decoded.role && ['user', 'streamer', 'global_mod', 'admin'].includes(decoded.role)) {
updates.push('role = ?'); params.push(decoded.role);
updates.push('role = ?'); params.push(decoded.role);
}
}
+ // A probe account is marked when it is created here too (server/auth/test-accounts.js keeps the list).
+ if (/^ovprobe_/i.test(String(decoded.username || ''))) updates.push('is_test = 1');
if (updates.length > 0) {
if (updates.length > 0) {
params.push(user.id);
params.push(user.id);
await db.getDb().prepare(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`).run(...params);
await db.getDb().prepare(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`).run(...params);
new file mode 100644
new file mode 100644
@@ -0,0 +1,75 @@
@@ -0,0 +1,75 @@
+'use strict';
+/**
+ * Test accounts on Live (OpenVibe.Network users.is_test).
+ *
+ * The probes sign in as real accounts, and Live's public people surfaces must never show them: Recently Online
+ * (the directory), the home digest's new-members faces and counts, and the hero's people counts. The identity
+ * authority lists its test accounts at GET /internal/identity/test-subjects (identity.subject.resolve); this module
+ * writes them into subject_projection with is_test = 1 and follows linked_accounts to the local users row, so the
+ * public queries filter `users.is_test` in SQL and never call Network while a page is read.
+ *
+ * A refresh replaces the whole flag set in one transaction: a subject the Network unmarks becomes ordinary again,
+ * and a failed refresh (Network down, no client secret) changes nothing, so the last good list keeps test accounts
+ * hidden. The account's own signed-in views are untouched: they read their own rows, not the public lists.
+ */
+const db = require('../db/database');
+const principal = require('../net/network-principal');
+const { OV_NETWORK_INTERNAL_URL } = require('../utils/notify');
+
+const REFRESH_MS = 60 * 60 * 1000;
+const TIMEOUT_MS = 8000;
+const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+let timer = null;
+let last = { at: null, count: 0, error: null };
+
+/** One full sync. Throws with a short, safe reason; never logs a token. */
+async function refresh({ fetchImpl = globalThis.fetch } = {}) {
+ const headers = await principal.headersFor('/internal/identity/test-subjects');
+ const res = await fetchImpl(`${OV_NETWORK_INTERNAL_URL}/internal/identity/test-subjects`, {
+ headers: { Accept: 'application/json', ...headers }, signal: AbortSignal.timeout(TIMEOUT_MS),
+ });
+ if (res.status === 401) principal.tokenRejected('401');
+ const body = await res.json().catch(() => null);
+ if (!res.ok || !body || !Array.isArray(body.subjects)) {
+ throw new Error(`Network ${res.status}${body && (body.code || body.error) ? `: ${body.code || body.error}` : ''}`);
+ }
+ const rows = body.subjects.filter((p) => p && p.subject && SUBJECT_RE.test(String(p.subject.id)))
+ .map((p) => ({ subject: p.subject.id, network_user_id: Number.isInteger(p.network_user_id) ? p.network_user_id : null,
+ username: typeof p.username === 'string' ? p.username.slice(0, 64) : null }));
+ const d = db.getDb();
+ await d.tx(async () => {
+ await d.prepare('UPDATE users SET is_test = 0 WHERE is_test <> 0').run();
+ await d.prepare('UPDATE subject_projection SET is_test = 0 WHERE is_test <> 0').run();
+ for (const r of rows) {
+ // A projection the events may not have sent yet: the row is enough for the link, revision 0 never beats an event.
+ await d.prepare(`INSERT INTO subject_projection (subject_id, network_user_id, revision, username, role, is_test)
+ VALUES (?, ?, 0, ?, 'user', 1)
+ ON CONFLICT (subject_id) DO UPDATE SET is_test = 1,
+ network_user_id = COALESCE(excluded.network_user_id, subject_projection.network_user_id)`)
+ .run(r.subject, r.network_user_id, r.username);
+ if (r.username) await d.prepare('UPDATE users SET is_test = 1 WHERE lower(username) = lower(?)').run(r.username);
+ }
+ // The subject link is the authority when a local name and the Network's current name differ (a rename).
+ await d.prepare(`UPDATE users SET is_test = 1 WHERE id IN (
+ SELECT la.user_id FROM linked_accounts la JOIN subject_projection sp ON sp.subject_id = la.subject_id
+ WHERE sp.is_test = 1)`).run();
+ });
+ last = { at: new Date().toISOString(), count: rows.length, error: null };
+ if (rows.length) console.log(`[Test accounts] ${rows.length} marked (users.is_test)`);
+ return rows.length;
+}
+
+/** Refresh now (unless the caller already awaited one), then hourly; a failure leaves the last good flags. */
+function start({ refreshNow = true } = {}) {
+ if (timer) return timer;
+ if (refreshNow) refresh().catch((err) => { last.error = err.message; console.warn('[Test accounts] refresh failed:', err.message); });
+ timer = setInterval(() => {
+ refresh().catch((err) => { last.error = err.message; console.warn('[Test accounts] refresh failed:', err.message); });
+ }, REFRESH_MS);
+ if (timer.unref) timer.unref();
+ return timer;
+}
+
+function stop() { if (timer) clearInterval(timer); timer = null; }
+
+module.exports = { refresh, start, stop, status: () => ({ ...last }), REFRESH_MS };
@@ -1428,7 +1428,7 @@ async function getRecentlyOnlineStreamers(limit = 20, offset = 0) {
@@ -1428,7 +1428,7 @@ async function getRecentlyOnlineStreamers(limit = 20, offset = 0) {
FROM streams s
FROM streams s
JOIN users u ON s.user_id = u.id
JOIN users u ON s.user_id = u.id
LEFT JOIN streamer_overviews o ON o.user_id = u.id
LEFT JOIN streamer_overviews o ON o.user_id = u.id
- WHERE (s.is_live = 0 AND s.ended_at IS NOT NULL) OR s.is_live = 1
+ WHERE ((s.is_live = 0 AND s.ended_at IS NOT NULL) OR s.is_live = 1) AND COALESCE(u.is_test, 0) = 0
GROUP BY u.id, o.overview, o.overview_short
GROUP BY u.id, o.overview, o.overview_short
ORDER BY last_online_at DESC
ORDER BY last_online_at DESC
LIMIT ? OFFSET ?
LIMIT ? OFFSET ?
@@ -1437,9 +1437,9 @@ async function getRecentlyOnlineStreamers(limit = 20, offset = 0) {
@@ -1437,9 +1437,9 @@ async function getRecentlyOnlineStreamers(limit = 20, offset = 0) {
async function countRecentlyOnlineStreamers() {
async function countRecentlyOnlineStreamers() {
return (await get(`
return (await get(`
- SELECT COUNT(DISTINCT user_id) AS count
- FROM streams
- WHERE (is_live = 0 AND ended_at IS NOT NULL) OR is_live = 1
+ SELECT COUNT(DISTINCT s.user_id) AS count
+ FROM streams s JOIN users u ON u.id = s.user_id
+ WHERE ((s.is_live = 0 AND s.ended_at IS NOT NULL) OR s.is_live = 1) AND COALESCE(u.is_test, 0) = 0
`))?.count || 0;
`))?.count || 0;
}
}
@@ -1775,14 +1775,14 @@ async function _computeHomeStats() {
@@ -1775,14 +1775,14 @@ async function _computeHomeStats() {
vods: null,
vods: null,
clips: null,
clips: null,
liveSessions: await c(`SELECT COUNT(*) AS count FROM streams`),
liveSessions: await c(`SELECT COUNT(*) AS count FROM streams`),
- streamers: await c(`SELECT COUNT(DISTINCT user_id) AS count FROM streams WHERE user_id IS NOT NULL`),
+ streamers: await c(`SELECT COUNT(DISTINCT s.user_id) AS count FROM streams s JOIN users u ON u.id = s.user_id WHERE COALESCE(u.is_test, 0) = 0`),
// Distinct people who went live in the last seven days, and the seven before.
// Distinct people who went live in the last seven days, and the seven before.
- streamedWeek: await c(`SELECT COUNT(DISTINCT user_id) AS count FROM streams WHERE user_id IS NOT NULL AND created_at >= datetime('now', '-7 days')`),
- prevStreamedWeek: await c(`SELECT COUNT(DISTINCT user_id) AS count FROM streams WHERE user_id IS NOT NULL AND created_at >= datetime('now', '-14 days') AND created_at < datetime('now', '-7 days')`),
+ streamedWeek: await c(`SELECT COUNT(DISTINCT s.user_id) AS count FROM streams s JOIN users u ON u.id = s.user_id WHERE COALESCE(u.is_test, 0) = 0 AND s.created_at >= datetime('now', '-7 days')`),
+ prevStreamedWeek: await c(`SELECT COUNT(DISTINCT s.user_id) AS count FROM streams s JOIN users u ON u.id = s.user_id WHERE COALESCE(u.is_test, 0) = 0 AND s.created_at >= datetime('now', '-14 days') AND s.created_at < datetime('now', '-7 days')`),
// OpenVibe.Chat owns chat_messages (roadmap T3): the total comes from Chat's read API.
// OpenVibe.Chat owns chat_messages (roadmap T3): the total comes from Chat's read API.
// A Chat outage answers the cache, else null — never a 500 for the home page.
// A Chat outage answers the cache, else null — never a 500 for the home page.
chatMessages: await (async () => { try { const s = await require('../chat/chat-reads').siteStatsPeek(); return s && s.messages != null ? s.messages : null; } catch { return null; } })(),
chatMessages: await (async () => { try { const s = await require('../chat/chat-reads').siteStatsPeek(); return s && s.messages != null ? s.messages : null; } catch { return null; } })(),
- users: await c(`SELECT COUNT(*) AS count FROM users WHERE COALESCE(is_banned, 0) = 0`),
+ users: await c(`SELECT COUNT(*) AS count FROM users WHERE COALESCE(is_banned, 0) = 0 AND COALESCE(is_test, 0) = 0`),
anons: await c(`SELECT COUNT(*) AS count FROM anon_ip_mappings`),
anons: await c(`SELECT COUNT(*) AS count FROM anon_ip_mappings`),
follows: await c(`SELECT COUNT(*) AS count FROM follows`),
follows: await c(`SELECT COUNT(*) AS count FROM follows`),
// No platform-wide emote total: OpenVibe.Chat owns `emotes` (roadmap T3) and exposes counts
// No platform-wide emote total: OpenVibe.Chat owns `emotes` (roadmap T3) and exposes counts
@@ -564,7 +564,7 @@ router.get('/digest', optionalAuth, async (req, res) => {
@@ -564,7 +564,7 @@ router.get('/digest', optionalAuth, async (req, res) => {
const uid = req.user ? req.user.id : null;
const uid = req.user ? req.user.id : null;
const followed = new Set(uid ? (await db.all('SELECT streamer_id FROM follows WHERE follower_id = ?', [uid]) || []).map(r => r.streamer_id) : []);
const followed = new Set(uid ? (await db.all('SELECT streamer_id FROM follows WHERE follower_id = ?', [uid]) || []).map(r => r.streamer_id) : []);
const liveNow = (await db.all(`SELECT u.id AS user_id, u.username, u.display_name, u.avatar_url, u.profile_color, s.title, s.viewer_count, s.started_at
const liveNow = (await db.all(`SELECT u.id AS user_id, u.username, u.display_name, u.avatar_url, u.profile_color, s.title, s.viewer_count, s.started_at
- FROM streams s JOIN users u ON u.id = s.user_id WHERE s.is_live = 1 AND COALESCE(u.is_banned, 0) = 0
+ FROM streams s JOIN users u ON u.id = s.user_id WHERE s.is_live = 1 AND COALESCE(u.is_banned, 0) = 0 AND COALESCE(u.is_test, 0) = 0
AND NOT (s.content_labels->'labels' @> '["18+"]'::jsonb)
AND NOT (s.content_labels->'labels' @> '["18+"]'::jsonb)
ORDER BY s.viewer_count DESC, s.started_at DESC LIMIT 10`) || []).map(r => ({ ...r, followed: followed.has(r.user_id) }));
ORDER BY s.viewer_count DESC, s.started_at DESC LIMIT 10`) || []).map(r => ({ ...r, followed: followed.has(r.user_id) }));
const liveIds = new Set(liveNow.map(r => r.user_id));
const liveIds = new Set(liveNow.map(r => r.user_id));
@@ -574,7 +574,7 @@ router.get('/digest', optionalAuth, async (req, res) => {
@@ -574,7 +574,7 @@ router.get('/digest', optionalAuth, async (req, res) => {
(SELECT title FROM streams t WHERE t.user_id = u.id AND t.started_at >= ? ORDER BY t.started_at DESC LIMIT 1) AS last_title,
(SELECT title FROM streams t WHERE t.user_id = u.id AND t.started_at >= ? ORDER BY t.started_at DESC LIMIT 1) AS last_title,
(SELECT id FROM streams t WHERE t.user_id = u.id AND t.started_at >= ? ORDER BY t.started_at DESC LIMIT 1) AS last_stream_id
(SELECT id FROM streams t WHERE t.user_id = u.id AND t.started_at >= ? ORDER BY t.started_at DESC LIMIT 1) AS last_stream_id
FROM streams s JOIN users u ON u.id = s.user_id
FROM streams s JOIN users u ON u.id = s.user_id
- WHERE s.started_at >= ? AND s.is_live = 0 AND COALESCE(u.is_banned, 0) = 0
+ WHERE s.started_at >= ? AND s.is_live = 0 AND COALESCE(u.is_banned, 0) = 0 AND COALESCE(u.is_test, 0) = 0
GROUP BY u.id ORDER BY hours DESC, last_at DESC LIMIT 14`, [sinceSql, sinceSql, sinceSql]) || [])
GROUP BY u.id ORDER BY hours DESC, last_at DESC LIMIT 14`, [sinceSql, sinceSql, sinceSql]) || [])
.filter(r => !liveIds.has(r.user_id));
.filter(r => !liveIds.has(r.user_id));
const streamed = [];
const streamed = [];
@@ -601,10 +601,10 @@ router.get('/digest', optionalAuth, async (req, res) => {
@@ -601,10 +601,10 @@ router.get('/digest', optionalAuth, async (req, res) => {
// rollback); a synchronous peek answers the last good count while Chat refreshes.
// rollback); a synchronous peek answers the last good count while Chat refreshes.
chat_lines: await (async () => { try { const s = await require('../chat/chat-reads').windowStatsPeek({ since: sinceMs }); return s ? s.messages : 0; } catch { return 0; } })(),
chat_lines: await (async () => { try { const s = await require('../chat/chat-reads').windowStatsPeek({ since: sinceMs }); return s ? s.messages : 0; } catch { return 0; } })(),
new_follows: Number((await one('SELECT COUNT(*) AS n FROM follows WHERE created_at >= ?', [sinceSql]) || {}).n || 0),
new_follows: Number((await one('SELECT COUNT(*) AS n FROM follows WHERE created_at >= ?', [sinceSql]) || {}).n || 0),
- new_members: Number((await one('SELECT COUNT(*) AS n FROM users WHERE created_at >= ? AND COALESCE(is_banned, 0) = 0 AND deleted_at IS NULL', [sinceSql]) || {}).n || 0),
+ new_members: Number((await one('SELECT COUNT(*) AS n FROM users WHERE created_at >= ? AND COALESCE(is_banned, 0) = 0 AND COALESCE(is_test, 0) = 0 AND deleted_at IS NULL', [sinceSql]) || {}).n || 0),
takes: Number((await one(`SELECT COUNT(*) AS n FROM rundown_segments s JOIN users u ON u.id = s.user_id
takes: Number((await one(`SELECT COUNT(*) AS n FROM rundown_segments s JOIN users u ON u.id = s.user_id
${rundownRead.AUDIENCE_JOINS('s')} WHERE s.status = 'live' AND s.said_at >= @since
${rundownRead.AUDIENCE_JOINS('s')} WHERE s.status = 'live' AND s.said_at >= @since
- AND COALESCE(u.is_banned, 0) = 0 AND ${rundownRead.VISIBLE('s')}`, { since: sinceSql, viewer: null }) || {}).n || 0),
+ AND COALESCE(u.is_banned, 0) = 0 AND COALESCE(u.is_test, 0) = 0 AND ${rundownRead.VISIBLE('s')}`, { since: sinceSql, viewer: null }) || {}).n || 0),
};
};
const previous = new Date(now - 86400000).toISOString().replace('T', ' ').slice(0, 19);
const previous = new Date(now - 86400000).toISOString().replace('T', ' ').slice(0, 19);
const prior = new Date(now - 2 * 86400000).toISOString().replace('T', ' ').slice(0, 19);
const prior = new Date(now - 2 * 86400000).toISOString().replace('T', ' ').slice(0, 19);
@@ -615,14 +615,14 @@ router.get('/digest', optionalAuth, async (req, res) => {
@@ -615,14 +615,14 @@ router.get('/digest', optionalAuth, async (req, res) => {
};
};
const publicTakes = async (start, end) => Number((await one(`SELECT COUNT(*) AS n FROM rundown_segments s JOIN users u ON u.id = s.user_id
const publicTakes = async (start, end) => Number((await one(`SELECT COUNT(*) AS n FROM rundown_segments s JOIN users u ON u.id = s.user_id
${rundownRead.AUDIENCE_JOINS('s')} WHERE s.status = 'live' AND s.said_at >= @start AND s.said_at < @end
${rundownRead.AUDIENCE_JOINS('s')} WHERE s.status = 'live' AND s.said_at >= @start AND s.said_at < @end
- AND COALESCE(u.is_banned, 0) = 0 AND ${rundownRead.VISIBLE('s')}`, { start, end, viewer: null }) || {}).n || 0);
+ AND COALESCE(u.is_banned, 0) = 0 AND COALESCE(u.is_test, 0) = 0 AND ${rundownRead.VISIBLE('s')}`, { start, end, viewer: null }) || {}).n || 0);
const changes = {
const changes = {
streams: await changeFor('streams', 'started_at'),
streams: await changeFor('streams', 'started_at'),
takes: await publicTakes(previous, new Date(now).toISOString().replace('T', ' ').slice(0, 19)) - await publicTakes(prior, previous),
takes: await publicTakes(previous, new Date(now).toISOString().replace('T', ' ').slice(0, 19)) - await publicTakes(prior, previous),
new_follows: await changeFor('follows', 'created_at'),
new_follows: await changeFor('follows', 'created_at'),
- new_members: await changeFor('users', 'created_at', 'COALESCE(is_banned, 0) = 0 AND deleted_at IS NULL'),
+ new_members: await changeFor('users', 'created_at', 'COALESCE(is_banned, 0) = 0 AND COALESCE(is_test, 0) = 0 AND deleted_at IS NULL'),
};
};
- const newPeople = (await db.all('SELECT username, display_name, avatar_url FROM users WHERE created_at >= ? AND COALESCE(is_banned, 0) = 0 AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 3', [sinceSql]) || []);
+ const newPeople = (await db.all('SELECT username, display_name, avatar_url FROM users WHERE created_at >= ? AND COALESCE(is_banned, 0) = 0 AND COALESCE(is_test, 0) = 0 AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 3', [sinceSql]) || []);
// A take is shown only when the stream is in this visit's window, the topic is live, and Rundown allows everyone to see it.
// A take is shown only when the stream is in this visit's window, the topic is live, and Rundown allows everyone to see it.
let topics = [];
let topics = [];
try {
try {
@@ -632,14 +632,14 @@ router.get('/digest', optionalAuth, async (req, res) => {
@@ -632,14 +632,14 @@ router.get('/digest', optionalAuth, async (req, res) => {
JOIN users u ON u.id = s.user_id JOIN rundown_topics t ON t.id = s.topic_id
JOIN users u ON u.id = s.user_id JOIN rundown_topics t ON t.id = s.topic_id
${AUDIENCE_JOINS('s')}
${AUDIENCE_JOINS('s')}
WHERE s.status = 'live' AND t.status = 'live' AND s.said_at >= @since AND stream.started_at >= @since
WHERE s.status = 'live' AND t.status = 'live' AND s.said_at >= @since AND stream.started_at >= @since
- AND s.score >= 4 AND COALESCE(u.is_banned, 0) = 0 AND ${VISIBLE('s')}
+ AND s.score >= 4 AND COALESCE(u.is_banned, 0) = 0 AND COALESCE(u.is_test, 0) = 0 AND ${VISIBLE('s')}
ORDER BY s.score DESC, s.said_at DESC LIMIT 40`, { since: sinceSql, viewer: null });
ORDER BY s.score DESC, s.said_at DESC LIMIT 40`, { since: sinceSql, viewer: null });
topics = selectTopics(rows);
topics = selectTopics(rows);
} catch { topics = []; }
} catch { topics = []; }
let nextStream = null;
let nextStream = null;
if (!stats.streams && !stats.takes && !stats.new_members && !stats.new_follows && !stats.chat_lines) {
if (!stats.streams && !stats.takes && !stats.new_members && !stats.new_follows && !stats.chat_lines) {
nextStream = await one(`SELECT a.title, a.starts_at, u.username, u.display_name FROM agent_stream_schedules a JOIN users u ON u.id = a.user_id
nextStream = await one(`SELECT a.title, a.starts_at, u.username, u.display_name FROM agent_stream_schedules a JOIN users u ON u.id = a.user_id
- WHERE a.state = 'scheduled' AND a.visibility = 'public' AND a.starts_at > ? AND COALESCE(u.is_banned, 0) = 0 ORDER BY a.starts_at LIMIT 1`, [new Date(now).toISOString()]);
+ WHERE a.state = 'scheduled' AND a.visibility = 'public' AND a.starts_at > ? AND COALESCE(u.is_banned, 0) = 0 AND COALESCE(u.is_test, 0) = 0 ORDER BY a.starts_at LIMIT 1`, [new Date(now).toISOString()]);
}
}
const across = await acrossOpenVibe(sinceMs);
const across = await acrossOpenVibe(sinceMs);
const digest = { since, signed_in: !!uid, liveNow, streamed, stats, topics, newPeople, changes, nextStream, across };
const digest = { since, signed_in: !!uid, liveNow, streamed, stats, topics, newPeople, changes, nextStream, across };
@@ -1524,6 +1524,15 @@ async function start() {
@@ -1524,6 +1524,15 @@ async function start() {
// 6f. VOD storage/offload is owned by OpenVibe.Media now — nothing to start here.
// 6f. VOD storage/offload is owned by OpenVibe.Media now — nothing to start here.
+ // 6h. Test accounts (Network users.is_test): mark the local users the public people surfaces must leave out.
+ // The first refresh is awaited before the listener opens, so the first public page already filters; a failure
+ // keeps the last good flags and the hourly timer still runs.
+ try {
+ const testAccounts = require('./auth/test-accounts');
+ await testAccounts.refresh().catch((err) => console.warn('[Test accounts] refresh failed:', err.message));
+ testAccounts.start({ refreshNow: false });
+ } catch (err) { console.warn('[Test accounts] not started:', err.message); }
+
// 7. Start HTTP server.
// 7. Start HTTP server.
//
//
// Zero-downtime restarts: when systemd owns the listening socket (a .socket unit with
// Zero-downtime restarts: when systemd owns the listening socket (a .socket unit with
@@ -30,12 +30,15 @@ const take = async (uid, sid, { title, score, quote = null, status = 'live', wit
@@ -30,12 +30,15 @@ const take = async (uid, sid, { title, score, quote = null, status = 'live', wit
[sid, uid, withTopic ? topic : null, title, `${title}.`, score, JSON.stringify(quote ? [{ text: quote, sec: 12, en: null }] : []), ago(150), status]);
[sid, uid, withTopic ? topic : null, title, `${title}.`, score, JSON.stringify(quote ? [{ text: quote, sec: 12, en: null }] : []), ago(150), status]);
const ann = await mk('annstreams'), bob = await mk('bobstreams'), ban = await mk('bannedone', { banned: true });
const ann = await mk('annstreams'), bob = await mk('bobstreams'), ban = await mk('bannedone', { banned: true });
-const sa = await stream(ann), sb = await stream(bob), sx = await stream(ban);
+await db.run("INSERT INTO users (username, email, password_hash, display_name, stream_key, is_test) VALUES ('ovprobe_digest', 'ovprobe@x', 'x', 'ovprobe_digest', ?, 1)", ['ovprobe-digest'.padEnd(32, '0')]);
+const probe = (await db.get('SELECT id FROM users WHERE username = ?', ['ovprobe_digest'])).id;
+const sa = await stream(ann), sb = await stream(bob), sx = await stream(ban), sp = await stream(probe);
await take(ann, sa, { title: 'Autofill is a scam on support mains', score: 5, quote: 'autofill put me on support again chat' });
await take(ann, sa, { title: 'Autofill is a scam on support mains', score: 5, quote: 'autofill put me on support again chat' });
await take(bob, sb, { title: 'Bob on his new keyboard', score: 4, withTopic: false });
await take(bob, sb, { title: 'Bob on his new keyboard', score: 4, withTopic: false });
await take(bob, sb, { title: 'A quiet stretch', score: 3 });
await take(bob, sb, { title: 'A quiet stretch', score: 3 });
await take(ann, sa, { title: 'Taken down', score: 5, quote: 'never shown', status: 'hidden' });
await take(ann, sa, { title: 'Taken down', score: 5, quote: 'never shown', status: 'hidden' });
await take(ban, sx, { title: 'Banned streamer take', score: 5, quote: 'never shown either' });
await take(ban, sx, { title: 'Banned streamer take', score: 5, quote: 'never shown either' });
+await take(probe, sp, { title: 'Test account take', score: 5, quote: 'a probe must not be public' });
const app = express();
const app = express();
app.use('/api/home', require('../server/home/routes'));
app.use('/api/home', require('../server/home/routes'));
@@ -51,6 +54,9 @@ try {
@@ -51,6 +54,9 @@ try {
assert.ok(d.html.includes(`/rundown/e/${sa}#rd-take-${d.topics[0].id}`));
assert.ok(d.html.includes(`/rundown/e/${sa}#rd-take-${d.topics[0].id}`));
assert.ok(d.html.includes('What they talked about'));
assert.ok(d.html.includes('What they talked about'));
assert.ok(d.html.includes('New here'));
assert.ok(d.html.includes('New here'));
+ assert.ok(!d.newPeople.some((p) => p.username === 'ovprobe_digest'), 'a test account is never a "New here" face');
+ assert.ok(!d.streamed.some((p) => p.username === 'ovprobe_digest'), 'a test account is never a "Who streamed" row');
+ assert.ok(!d.html.includes('ovprobe_digest') && !d.html.includes('Test account take'), 'the test account is nowhere in the digest');
assert.ok(!d.html.includes('Bob on his new keyboard'));
assert.ok(!d.html.includes('Bob on his new keyboard'));
console.log('✅ the digest reads The Rundown');
console.log('✅ the digest reads The Rundown');
} finally {
} finally {
@@ -22,9 +22,11 @@ const KEYS = ['streams_this_week', 'hours_this_week', 'usual_time', 'days_in_a_r
@@ -22,9 +22,11 @@ const KEYS = ['streams_this_week', 'hours_this_week', 'usual_time', 'days_in_a_r
(async () => {
(async () => {
await db.initDb();
await db.initDb();
const mk = async (username) => { await db.createUser({ username, email: `${username}@x`, password_hash: 'x', display_name: username, stream_key: username.padEnd(32, '0') }); const id = (await db.get('SELECT id FROM users WHERE username = ?', [username])).id; await db.ensureChannel(id); return id; };
const mk = async (username) => { await db.createUser({ username, email: `${username}@x`, password_hash: 'x', display_name: username, stream_key: username.padEnd(32, '0') }); const id = (await db.get('SELECT id FROM users WHERE username = ?', [username])).id; await db.ensureChannel(id); return id; };
- const pic = await mk('picasso'), nov = await mk('novod'), liv = await mk('liveone');
+ const pic = await mk('picasso'), nov = await mk('novod'), liv = await mk('liveone'), probe = await mk('ovprobe_recently');
const slot = async (uid, slug) => Number((await db.run('INSERT INTO managed_streams (user_id, slug, title, protocol, stream_key, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ov_now(), ov_now()) RETURNING id', [uid, slug, `${slug} title`, 'webrtc', `${slug}-key-${uid}`])).lastInsertRowid);
const slot = async (uid, slug) => Number((await db.run('INSERT INTO managed_streams (user_id, slug, title, protocol, stream_key, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ov_now(), ov_now()) RETURNING id', [uid, slug, `${slug} title`, 'webrtc', `${slug}-key-${uid}`])).lastInsertRowid);
- const picSlot = await slot(pic, 'main'), livSlot = await slot(liv, 'main');
+ const picSlot = await slot(pic, 'main'), livSlot = await slot(liv, 'main'), probeSlot = await slot(probe, 'main');
+ // A test account (OpenVibe.Network users.is_test) with a stream must never be a public card.
+ await db.run('UPDATE users SET is_test = 1 WHERE id = ?', [probe]);
const stream = async (uid, ms, { live = false, startedAgo, endedAgo = null, thumb = null }) => await db.run(
const stream = async (uid, ms, { live = false, startedAgo, endedAgo = null, thumb = null }) => await db.run(
`INSERT INTO streams (user_id, managed_stream_id, title, protocol, is_live, viewer_count, started_at, ended_at, thumbnail_url, created_at)
`INSERT INTO streams (user_id, managed_stream_id, title, protocol, is_live, viewer_count, started_at, ended_at, thumbnail_url, created_at)
VALUES (?, ?, ?, 'webrtc', ?, 0, to_char(now() - make_interval(mins => ?), 'YYYY-MM-DD HH24:MI:SS'), ${live ? 'NULL' : "to_char(now() - make_interval(mins => ?), 'YYYY-MM-DD HH24:MI:SS')"}, ?, ov_now())`,
VALUES (?, ?, ?, 'webrtc', ?, 0, to_char(now() - make_interval(mins => ?), 'YYYY-MM-DD HH24:MI:SS'), ${live ? 'NULL' : "to_char(now() - make_interval(mins => ?), 'YYYY-MM-DD HH24:MI:SS')"}, ?, ov_now())`,
@@ -36,6 +38,7 @@ const KEYS = ['streams_this_week', 'hours_this_week', 'usual_time', 'days_in_a_r
@@ -36,6 +38,7 @@ const KEYS = ['streams_this_week', 'hours_this_week', 'usual_time', 'days_in_a_r
await stream(nov, null, { startedAgo: 700, endedAgo: 650, thumb: '/api/thumbnails/stream-9103-3.jpg' });
await stream(nov, null, { startedAgo: 700, endedAgo: 650, thumb: '/api/thumbnails/stream-9103-3.jpg' });
// A live stream: its capture still exists.
// A live stream: its capture still exists.
await stream(liv, livSlot, { live: true, startedAgo: 10, thumb: '/api/thumbnails/stream-9104-live.jpg' });
await stream(liv, livSlot, { live: true, startedAgo: 10, thumb: '/api/thumbnails/stream-9104-live.jpg' });
+ await stream(probe, probeSlot, { startedAgo: 400, endedAgo: 350, thumb: '/api/thumbnails/stream-9105-probe.jpg' });
// A stored card line in the old shape (name first, colon, two sentences).
// A stored card line in the old shape (name first, colon, two sentences).
await db.run("INSERT INTO streamer_overviews (user_id, overview, overview_short, model, sources, generated_at) VALUES (?, ?, ?, 'x', '{}', ov_now())",
await db.run("INSERT INTO streamer_overviews (user_id, overview, overview_short, model, sources, generated_at) VALUES (?, ?, ?, 'x', '{}', ov_now())",
@@ -63,6 +66,9 @@ const KEYS = ['streams_this_week', 'hours_this_week', 'usual_time', 'days_in_a_r
@@ -63,6 +66,9 @@ const KEYS = ['streams_this_week', 'hours_this_week', 'usual_time', 'days_in_a_r
const by = (u) => body.streamers.find((s) => s.username === u);
const by = (u) => body.streamers.find((s) => s.username === u);
const p = by('picasso'), n = by('novod'), l = by('liveone');
const p = by('picasso'), n = by('novod'), l = by('liveone');
assert.ok(p && n && l, JSON.stringify(body.streamers.map((s) => s.username)));
assert.ok(p && n && l, JSON.stringify(body.streamers.map((s) => s.username)));
+ assert.strictEqual(by('ovprobe_recently'), undefined, 'a test account is never on Recently Online');
+ assert.ok(!JSON.stringify(body).includes('ovprobe_recently'), 'the test account is nowhere in the public read');
+ assert.strictEqual(body.streamers.length, 3, 'the probe is counted out too');
// The replay rows picture the recording, not the deleted capture.
// The replay rows picture the recording, not the deleted capture.
assert.strictEqual(p.recent_facts.streams.length, 2);
assert.strictEqual(p.recent_facts.streams.length, 2);
@@ -360,6 +360,7 @@ async function check(name, fn) {
@@ -360,6 +360,7 @@ async function check(name, fn) {
'server/utils/notify.js': 'OpenVibe.Network (configured)',
'server/utils/notify.js': 'OpenVibe.Network (configured)',
'server/auth/identity-sync.js': 'OpenVibe.Network (configured)',
'server/auth/identity-sync.js': 'OpenVibe.Network (configured)',
'server/auth/account-data.js': 'OpenVibe.Network (configured): export parts and deletion confirmations',
'server/auth/account-data.js': 'OpenVibe.Network (configured): export parts and deletion confirmations',
+ 'server/auth/test-accounts.js': 'OpenVibe.Network (configured OV_NETWORK_INTERNAL_URL): the fixed /internal/identity/test-subjects path, no URL from a person',
'server/agent-streams/sharing.js': 'OpenVibe.Network (configured): the sharing system\'s name lookup and person resolution',
'server/agent-streams/sharing.js': 'OpenVibe.Network (configured): the sharing system\'s name lookup and person resolution',
'server/rundown/citations.js': 'OpenVibe.Report public session-report list (OV_REPORT_URL, configured; fixed path, no URL from a person)',
'server/rundown/citations.js': 'OpenVibe.Report public session-report list (OV_REPORT_URL, configured; fixed path, no URL from a person)',
'server/ai/viewer-quota.js': 'OpenVibe.AI (configured OV_AI_INTERNAL_URL): a streamer\'s daily AI-viewer cap',
'server/ai/viewer-quota.js': 'OpenVibe.AI (configured OV_AI_INTERNAL_URL): a streamer\'s daily AI-viewer cap',
new file mode 100644
new file mode 100644
@@ -0,0 +1,61 @@
@@ -0,0 +1,61 @@
+'use strict';
+// Test accounts on Live (server/auth/test-accounts.js): the identity authority's is_test list becomes users.is_test,
+// follows linked accounts by subject, and replaces the whole set on success; a failed refresh keeps the last good
+// flags so a Network outage never makes a probe public again.
+// node --import ./test/helpers/pg-preload.mjs test/test-accounts.test.js
+const assert = require('assert');
+
+process.env.NODE_ENV = 'test';
+console.warn = () => {};
+const quiet = console.log;
+console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
+
+const db = require('../server/db/database');
+const principal = require('../server/net/network-principal');
+const testAccounts = require('../server/auth/test-accounts');
+
+const SUBJECT = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';
+const OTHER = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR';
+
+(async () => {
+ await db.initDb();
+ const d = db.getDb();
+ const user = await d.prepare("INSERT INTO users (username, password_hash, display_name) VALUES ('ovprobe_live', 'x', 'ovprobe_live') RETURNING id").get();
+ const linked = await d.prepare("INSERT INTO users (username, password_hash, display_name) VALUES ('renamed_probe', 'x', 'renamed_probe') RETURNING id").get();
+ await d.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (?, 'network', '410', ?)").run(linked.id, SUBJECT);
+ await d.prepare("UPDATE users SET is_test = 0").run();
+
+ // The token is minted by the same principal the service uses; the Network answer is this stub.
+ principal.headersFor = async () => ({ authorization: 'Bearer test-service-token' });
+ let answer = { subjects: [{ subject: { type: 'user', id: SUBJECT }, network_user_id: 410, username: 'ovprobe_live', is_test: true }] };
+ let down = false;
+ const fetchImpl = async (url) => {
+ if (down) return new Response('{}', { status: 503 });
+ assert.ok(String(url).endsWith('/internal/identity/test-subjects'));
+ return new Response(JSON.stringify(answer), { status: 200, headers: { 'content-type': 'application/json' } });
+ };
+
+ assert.strictEqual(await testAccounts.refresh({ fetchImpl }), 1);
+ assert.strictEqual(Number((await d.prepare('SELECT is_test FROM users WHERE id = ?').get(user.id)).is_test), 1, 'the probe name is marked');
+ assert.strictEqual(Number((await d.prepare('SELECT is_test FROM users WHERE id = ?').get(linked.id)).is_test), 1, 'a renamed account follows its subject link');
+ assert.strictEqual(Number((await d.prepare('SELECT is_test FROM subject_projection WHERE subject_id = ?').get(SUBJECT)).is_test), 1, 'the projection carries the flag');
+
+ // A failed refresh changes nothing.
+ down = true;
+ await assert.rejects(testAccounts.refresh({ fetchImpl }));
+ assert.strictEqual(Number((await d.prepare('SELECT is_test FROM users WHERE id = ?').get(user.id)).is_test), 1, 'the last good list stays after an outage');
+
+ // A successful refresh with an empty list unmarks everyone (the Network no longer calls them test accounts).
+ down = false; answer = { subjects: [] };
+ assert.strictEqual(await testAccounts.refresh({ fetchImpl }), 0);
+ assert.strictEqual(Number((await d.prepare('SELECT is_test FROM users WHERE id = ?').get(user.id)).is_test), 0);
+ assert.strictEqual(Number((await d.prepare('SELECT is_test FROM users WHERE id = ?').get(linked.id)).is_test), 0);
+ assert.strictEqual(Number((await d.prepare('SELECT is_test FROM subject_projection WHERE subject_id = ?').get(SUBJECT)).is_test), 0);
+
+ // A second subject is fine too and the module never trusts a malformed id.
+ answer = { subjects: [{ subject: { type: 'user', id: OTHER }, network_user_id: 411, username: 'ovprobe_two' }, { subject: { type: 'user', id: 'nope' } }] };
+ assert.strictEqual(await testAccounts.refresh({ fetchImpl }), 1);
+
+ console.log('test accounts: marked by name and by subject link, kept through an outage, unmarked by a new list');
+ process.exit(0);
+})().catch((e) => { console.error(e); process.exit(1); });