openvibers/openvibe.live Commits
Arena on stream: a streamer runs a Bench round, chat votes, Live casts one audience_poll (#433)
Changes
@@@ -195,3 +195,4 @@ OV_BILLING_PUBLIC_URL=https://billing.openvibe.network# Live reads and writes them with its service token (audience openvibe.inventory) acting for the person.#INVENTORY_URL=http://127.0.0.1:5030# OV_BILLING_TIMEOUT_MS=8000++OV_BENCH_INTERNAL_URL=http://127.0.0.1:5230nnew file mode 100644@@@ -0,0 +1,11 @@++# Bench on stream++++A streamer starts a round from the Dashboard while live. Live asks Bench to create a public task and round, then waits for two usable entries. The watch page shows both entries. HTML entries use an empty iframe sandbox with a CSP that blocks scripts and network requests, matching Bench's Design HTML rules.++++When the entries are ready, Live posts one chat line opening a 15–600 second vote. OpenVibe.Chat handles `!1`, `!2`, and `!tie` and forwards the signed-in viewer's Live user id and stream id through `POST /internal/chat-effects/bench-vote`. Live verifies the user's Network subject, excludes the streamer, and stores one vote per user per poll. A background job closes votes after the deadline and survives a restart because the poll, deadline, and votes are in Live's database.++++Live casts a single Bench service judgement with `judge: audience_poll`, the two entry ids, the plurality outcome, and `vote_counts: { left, right, tie, voters }`. The result is a tie when `!tie` leads or the lead is shared. Bench treats a poll with viewers as one Elo game regardless of voter count; a zero-voter poll is recorded without changing ratings. The counts remain on the judgement for audit and the open dataset. Live then posts the result in chat.++++Network must grant Live's service principal `bench.task.create`, `bench.round.create`, and `bench.judgement.cast` for audience `openvibe.bench`. Round reads are public. Set `OV_BENCH_INTERNAL_URL` to Bench's internal origin (default `http://127.0.0.1:5230`). Chat's existing `live.chat_effects.write` grant covers the vote effect.++++Live passes the streamer's verified Network subject on task and round creation, so the daily per-person quota belongs to the streamer. Bench accepts this ownership header only from `service:live`; the judgement always belongs to the Live service. A pending poll is abandoned if its stream ends before its entries arrive.nnew file mode 100644@@@ -0,0 +1,24 @@++-- phase: expand++CREATE TABLE live_bench_polls (++ id bigserial PRIMARY KEY,++ streamer_id bigint NOT NULL REFERENCES users(id),++ stream_id bigint NOT NULL REFERENCES streams(id),++ round_id text NOT NULL UNIQUE,++ kind_id text NOT NULL,++ prompt text NOT NULL,++ status text NOT NULL CHECK (status IN ('pending', 'open', 'closing', 'closed', 'failed')),++ duration_seconds integer NOT NULL,++ closes_at timestamptz,++ entries jsonb,++ vote_counts jsonb,++ judgement_id text,++ created_at timestamptz NOT NULL DEFAULT now()++);++CREATE INDEX live_bench_polls_by_streamer ON live_bench_polls (streamer_id, id DESC);++CREATE UNIQUE INDEX live_bench_one_active_per_streamer ON live_bench_polls (streamer_id) WHERE status IN ('pending', 'open', 'closing');++CREATE TABLE live_bench_votes (++ poll_id bigint NOT NULL REFERENCES live_bench_polls(id) ON DELETE CASCADE,++ user_id bigint NOT NULL REFERENCES users(id),++ choice text NOT NULL CHECK (choice IN ('left', 'right', 'tie')),++ PRIMARY KEY (poll_id, user_id)++);@@@ -24,7 +24,7 @@"node-media-server": "^2.7.1","onnxruntime-node": "^1.20.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.71.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0","pg": "^8.23.1","sanitize-html": "^2.17.7",@@@ -2660,9 +2660,9 @@}},"node_modules/openvibe-sdk": {-- "version": "0.67.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",-- "integrity": "sha512-s7NzjaNcowVcOIiup2z0ze+xsq/qnr++3pHSI8JzirqJPQ5JsBSUF5RkjOHyfk9bRKlmhY0fsNS6x4dsYylyqw==",++ "version": "0.71.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.71.0",++ "integrity": "sha512-5cGZrMtY6sUsYDiXTw40gn7Lu4LCJI0UvvcO7oWHPAQ+QNN9QeRd4LC8jT67nq2C7GsgS3KN0aDA+3tV2J4avw==","license": "MIT","bin": {"openvibe-download": "bin/openvibe-download.js"@@@ -43,7 +43,7 @@"node-media-server": "^2.7.1","onnxruntime-node": "^1.20.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.71.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0","pg": "^8.23.1","sanitize-html": "^2.17.7",@@@ -114,7 +114,8 @@"/js/channel-socials.js","/js/channel-chat-resize.js","/js/channel-support.js",-- "/js/app-channel.js"++ "/js/app-channel.js",++ "/js/bench-stream-watch.js"],"stubs": ["toggleChannelChat"@@@ -240,7 +241,8 @@"/js/settings.js","/js/dashboard-moderation.js","/js/powerchat.js",-- "/js/dashboard.js"++ "/js/dashboard.js",++ "/js/bench-stream-dashboard.js"],"stubs": ["regenerateDashStreamKey"@@@ -8,6 +8,7 @@<!-- Live Stream Area (edge-to-edge) --><div class="ch-live-area" id="ch-live-area" style="display:none">++ <section class="dash-card" id="ch-bench-stream" aria-label="Bench vote" hidden></section><div class="stream-layout"><div class="stream-main"><div class="video-container" id="video-container">@@@ -14,7 +14,28 @@<button class="ch-tab" role="tab" aria-selected="false" data-dtab="points" onclick="switchDashTab('points', this)"><i class="fa-solid fa-star"></i> Points</button></div>-- <div class="ch-tab-panel dash-tab-panel active" id="dash-panel-home" role="tabpanel"><div class="dash-grid" id="dash-grid-home"></div></div>++ <div class="ch-tab-panel dash-tab-panel active" id="dash-panel-home" role="tabpanel"><div class="dash-grid" id="dash-grid-home">++ <section class="dash-card" id="dash-bench-stream" aria-labelledby="dash-bench-title">++ <h3 id="dash-bench-title">Bench on stream</h3>++ <form id="dash-bench-form">++ <label for="dash-bench-kind">Kind</label>++ <select class="form-input" id="dash-bench-kind" name="kind" required>++ <option value="design.page">Design page</option><option value="design.component">Design component</option>++ <option value="meme.caption">Meme caption</option><option value="chat.reply">Chat reply</option>++ <option value="games.fun">Games fun</option><option value="vision.pair">Vision pair</option>++ <option value="codes.review">Codes review</option><option value="surf.desk">Surf desk</option>++ <option value="speech.tts">Speech TTS</option><option value="translate.ja">Translate Japanese</option>++ <option value="search.answer">Search answer</option>++ </select>++ <label for="dash-bench-prompt">Prompt</label>++ <textarea class="form-input" id="dash-bench-prompt" name="prompt" maxlength="4000" required></textarea>++ <label for="dash-bench-duration">Vote time (seconds)</label>++ <input class="form-input" id="dash-bench-duration" name="duration" type="number" min="15" max="600" value="60" required>++ <button class="btn" type="submit">Start round</button>++ </form>++ <p id="dash-bench-status" role="status"></p>++ </section>++ </div></div><div class="ch-tab-panel dash-tab-panel" id="dash-panel-chatai" role="tabpanel"><div class="dash-grid" id="dash-grid-chatai"></div></div><div class="ch-tab-panel dash-tab-panel" id="dash-panel-moderation" role="tabpanel"><div class="dash-grid" id="dash-grid-moderation"></div></div><div class="ch-tab-panel dash-tab-panel" id="dash-panel-controls" role="tabpanel"><div class="dash-grid" id="dash-grid-controls"></div></div>nnew file mode 100644@@@ -0,0 +1,33 @@++/* The dashboard's Bench round starter. Chat votes are collected by Live after Bench finishes the entries. */++(function () {++ const form = document.getElementById('dash-bench-form');++ const status = document.getElementById('dash-bench-status');++ if (!form || !status) return;++ async function refresh() {++ try {++ const { poll } = await api('/bench-stream/mine');++ if (!poll) {++ status.textContent = '';++ form.querySelector('button').disabled = false;++ return;++ }++ status.textContent = `Round ${poll.round_id}: ${poll.status}${poll.status === 'open' ? ` — vote !1, !2 or !tie until ${new Date(poll.closes_at).toLocaleTimeString()}` : ''}`;++ form.querySelector('button').disabled = ['pending', 'open', 'closing'].includes(poll.status);++ } catch (e) { status.textContent = e.message || 'Could not load Bench.'; }++ }++ form.addEventListener('submit', async (event) => {++ event.preventDefault();++ const button = form.querySelector('button');++ button.disabled = true;++ status.textContent = 'Starting Bench round…';++ try {++ const { poll } = await api('/bench-stream/mine', { method: 'POST', body: {++ kind: form.elements.kind.value, prompt: form.elements.prompt.value,++ duration_seconds: Number(form.elements.duration.value),++ } });++ status.textContent = `Round ${poll.round_id} is generating its entries.`;++ } catch (e) { status.textContent = e.message || 'Could not start Bench round.'; button.disabled = false; }++ });++ refresh();++ setInterval(() => { if (document.getElementById('page-dashboard')?.classList.contains('active')) refresh(); }, 5000);++})();nnew file mode 100644@@@ -0,0 +1,81 @@++/* Plain watch-page side panel; the main session owns the visual treatment. */++(function () {++ const panel = document.getElementById('ch-bench-stream');++ if (!panel) return;++ let shown = '';++ function entryView(entry, number) {++ const box = document.createElement('article');++ box.className = 'dash-card';++ const heading = document.createElement('h4');++ heading.textContent = `Entry ${number}`;++ box.append(heading);++ const payload = entry.payload || {};++ if (typeof payload.html === 'string') {++ const frame = document.createElement('iframe');++ frame.title = `Bench entry ${number}`;++ frame.setAttribute('sandbox', '');++ frame.setAttribute('referrerpolicy', 'no-referrer');++ frame.srcdoc = '<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">'++ + '<meta http-equiv="Content-Security-Policy" content="default-src \'none\'; style-src \'unsafe-inline\'; img-src data:">'++ + '<style>html,body{margin:0;min-height:100%;background:#fff}img{max-width:100%;height:auto}</style>' + payload.html;++ box.append(frame);++ } else {++ for (const [key, value] of Object.entries(payload)) {++ if (typeof value !== 'string') {++ if (value == null) continue;++ const pre = document.createElement('pre');++ pre.textContent = `${key}: ${JSON.stringify(value, null, 2)}`;++ box.append(pre);++ continue;++ }++ if (key === 'image' && (/^https:\/\//i.test(value) || /^data:image\/(?:png|jpeg|gif|webp);base64,/i.test(value))) {++ const img = document.createElement('img');++ img.src = value;++ img.alt = `Bench entry ${number}`;++ img.loading = 'lazy';++ box.append(img);++ continue;++ }++ if (key === 'audio_url' && /^https:\/\//i.test(value)) {++ const audio = document.createElement('audio');++ audio.controls = true;++ audio.src = value;++ box.append(audio);++ continue;++ }++ const p = document.createElement('p');++ p.textContent = `${key}: ${value}`;++ box.append(p);++ }++ }++ return box;++ }++ async function refresh() {++ const username = typeof currentChannelUsername === 'string' ? currentChannelUsername : null;++ if (!username || !location.pathname.startsWith('/@')) return;++ try {++ const { poll } = await api(`/bench-stream/channel/${encodeURIComponent(username)}`);++ if (username !== currentChannelUsername || !location.pathname.startsWith('/@')) return;++ if (!poll || poll.status === 'failed' || (typeof currentStreamId !== 'undefined' && Number(currentStreamId) !== poll.stream_id)) {++ panel.hidden = true; shown = ''; return;++ }++ const key = `${poll.round_id}:${poll.status}`;++ if (shown === key) return;++ shown = key;++ panel.replaceChildren();++ const title = document.createElement('h3');++ title.textContent = `Bench: ${poll.kind_id}`;++ const prompt = document.createElement('p');++ prompt.textContent = poll.prompt;++ const state = document.createElement('p');++ state.textContent = poll.status === 'open' ? `Vote in chat: !1, !2 or !tie. Closes ${new Date(poll.closes_at).toLocaleTimeString()}.`++ : poll.status === 'closed' ? `Result: ${poll.vote_counts.left} for 1, ${poll.vote_counts.right} for 2, ${poll.vote_counts.tie} ties (${poll.vote_counts.voters} voters).`++ : 'Entries are generating…';++ panel.append(title, prompt, state);++ if (poll.entries) for (const [i, entry] of poll.entries.entries()) panel.append(entryView(entry, i + 1));++ panel.hidden = false;++ } catch { /* Chat and the stream stay usable while Bench is unavailable. */ }++ }++ refresh();++ setInterval(refresh, 3000);++})();nnew file mode 100644@@@ -0,0 +1,22 @@++'use strict';++++const { createClient } = require('openvibe-sdk/core');++const { createBenchClient } = require('openvibe-sdk/bench');++const principal = require('../net/network-principal');++++let bench;++function client() {++ if (require('../drill').enabled) throw new Error('Bench is unavailable in a restore drill');++ if (!principal.configured()) throw new Error('Live has no service credentials for Bench');++ if (!bench) {++ const baseUrl = (process.env.OV_BENCH_INTERNAL_URL || 'http://127.0.0.1:5230').replace(/\/+$/, '');++ bench = createBenchClient(createClient({++ baseUrls: { bench: baseUrl }, autoDiscover: false, retries: 0,++ getToken: async () => (await principal.serviceHeaders('openvibe.bench')).Authorization.replace(/^Bearer\s+/i, ''),++ invalidateToken: () => principal.invalidate('openvibe.bench'),++ }));++ }++ return bench;++}++++module.exports = { client };nnew file mode 100644@@@ -0,0 +1,38 @@++'use strict';++++const express = require('express');++const db = require('../db/database');++const { requireAuth } = require('../auth/auth');++const poll = require('./service');++const router = express.Router();++++router.get('/channel/:username', async (req, res) => {++ res.set('Cache-Control', 'no-store');++ try {++ const user = await db.getUserByUsername(req.params.username);++ if (!user) return res.status(404).json({ error: 'Channel not found' });++ return res.json({ poll: await poll.current(user.id) });++ } catch (err) { console.warn('[BenchStream] read:', err.message); return res.status(503).json({ error: 'Bench is unavailable' }); }++});++++router.get('/mine', requireAuth, async (req, res) => {++ res.set('Cache-Control', 'no-store');++ try { return res.json({ poll: await poll.current(req.user.id) }); }++ catch (err) { console.warn('[BenchStream] mine:', err.message); return res.status(503).json({ error: 'Bench is unavailable' }); }++});++++router.post('/mine', requireAuth, async (req, res) => {++ res.set('Cache-Control', 'no-store');++ try {++ const b = req.body || {};++ return res.status(201).json({ poll: await poll.start(req.user.id, {++ kind: b.kind, prompt: b.prompt, durationSeconds: b.duration_seconds == null ? 60 : b.duration_seconds,++ }) });++ } catch (err) {++ if (/^(Choose|Write|Vote time|Go live|Finish|Sign in again)/.test(err.message)) return res.status(400).json({ error: err.message });++ console.warn('[BenchStream] start:', err.message);++ return res.status(503).json({ error: 'Bench could not start the round' });++ }++});++++module.exports = router;nnew file mode 100644@@@ -0,0 +1,122 @@++'use strict';++++const db = require('../db/database');++const { client } = require('./bench-client');++const delivery = require('../chat/chat-delivery');++const { publicBenchPoll: publicPoll } = require('../web/serializers');++++const VALID_KINDS = new Set(['design.page', 'design.component', 'games.fun', 'meme.caption', 'vision.pair', 'codes.review', 'surf.desk', 'chat.reply', 'speech.tts', 'translate.ja', 'search.answer']);++++async function line(row, message, suffix) {++ await delivery.message({ stream_id: Number(row.stream_id), channel_user_id: Number(row.streamer_id), user_id: null,++ username: 'OpenVibe.Bench', message, message_type: 'system',++ metadata: { kind: 'bench_stream', round_id: row.round_id }, key: `bench:${row.round_id}:${suffix}` });++}++++async function start(streamerId, { kind, prompt, durationSeconds = 60 }) {++ if (!VALID_KINDS.has(kind)) throw new Error('Choose a pairwise Bench kind.');++ if (typeof prompt !== 'string' || !prompt.trim() || prompt.length > 4000) throw new Error('Write a prompt of at most 4000 characters.');++ if (!Number.isInteger(durationSeconds) || durationSeconds < 15 || durationSeconds > 600) throw new Error('Vote time must be 15 to 600 seconds.');++ const streams = await db.getLiveStreamsByUserId(streamerId);++ const stream = streams && streams[0];++ if (!stream) throw new Error('Go live before starting a Bench round.');++ const active = await db.get("SELECT id FROM live_bench_polls WHERE streamer_id = ? AND status IN ('pending', 'open', 'closing') LIMIT 1", [streamerId]);++ if (active) throw new Error('Finish the current Bench round first.');++ const subject = await require('../auth/identity-sync').subjectOf(streamerId);++ if (!subject) throw new Error('Sign in again to start a Bench round.');++ const task = await client().tasks.create(kind, { prompt: prompt.trim(), public: true }, { actingSubject: subject });++ const created = await client().rounds.create(task.task.id, {}, { actingSubject: subject });++ try {++ await db.run(`INSERT INTO live_bench_polls (streamer_id, stream_id, round_id, kind_id, prompt, status, duration_seconds)++ VALUES (?, ?, ?, ?, ?, 'pending', ?)`, [streamerId, stream.id, created.round.id, kind, prompt.trim(), durationSeconds]);++ } catch (err) {++ if (err.code === '23505') throw new Error('Finish the current Bench round first.');++ throw err;++ }++ return await current(streamerId);++}++++async function current(streamerId) {++ const row = await db.get(`SELECT p.* FROM live_bench_polls p JOIN streams s ON s.id = p.stream_id++ WHERE p.streamer_id = ? AND s.is_live = 1 ORDER BY p.id DESC LIMIT 1`, [streamerId]);++ if (!row) return null;++ return publicPoll(row);++}++++async function advance(row) {++ if (row.status === 'pending') {++ const stream = await db.getStreamById(row.stream_id);++ if (!stream || !stream.is_live) {++ await db.run("UPDATE live_bench_polls SET status = 'failed' WHERE id = ? AND status = 'pending'", [row.id]);++ return;++ }++ const { round } = await client().rounds.get(row.round_id);++ if (round.status === 'failed') {++ await db.run("UPDATE live_bench_polls SET status = 'failed' WHERE id = ? AND status = 'pending'", [row.id]);++ return;++ }++ const entries = (round.entries || []).filter((e) => !e.error).slice(0, 2);++ if (round.status !== 'finished' || entries.length !== 2) return;++ const opened = await db.run(`UPDATE live_bench_polls SET status = 'open', entries = ?, closes_at = now() + duration_seconds * interval '1 second'++ WHERE id = ? AND status = 'pending'`, [JSON.stringify(entries), row.id]);++ if (opened.changes) await line(row, `Bench vote is open for ${row.duration_seconds}s: !1, !2 or !tie. ${row.prompt}`.slice(0, 480), 'open');++ return;++ }++ if (row.status !== 'open' && row.status !== 'closing') return;++ if (row.status === 'open' && new Date(row.closes_at).getTime() > Date.now()) return;++ if (row.status === 'open') {++ const claimed = await db.run("UPDATE live_bench_polls SET status = 'closing' WHERE id = ? AND status = 'open'", [row.id]);++ if (!claimed.changes) return;++ }++ const counts = { left: 0, right: 0, tie: 0, voters: 0 };++ for (const v of await db.all('SELECT choice, COUNT(*)::int AS n FROM live_bench_votes WHERE poll_id = ? GROUP BY choice', [row.id])) {++ counts[v.choice] = Number(v.n);++ counts.voters += Number(v.n);++ }++ const entries = typeof row.entries === 'string' ? JSON.parse(row.entries) : row.entries;++ const outcome = counts.left > counts.right && counts.left > counts.tie ? 'left'++ : counts.right > counts.left && counts.right > counts.tie ? 'right' : 'tie';++ let judgementId = row.judgement_id;++ if (!judgementId) {++ try {++ const result = await client().judgements.cast(row.round_id, { judge: 'audience_poll', pair: { left: entries[0].id, right: entries[1].id }, outcome, vote_counts: counts });++ judgementId = result.judgement.id;++ } catch (err) {++ if (err.status !== 409 || err.code !== 'bench.already_judged') throw err;++ // A previous close reached Bench and crashed before saving the receipt. Bench's one-service-per-round rule prevents a duplicate.++ judgementId = 'already-recorded';++ }++ }++ await db.run("UPDATE live_bench_polls SET status = 'closed', vote_counts = ?, judgement_id = ? WHERE id = ? AND status = 'closing'", [JSON.stringify(counts), judgementId, row.id]);++ const summary = `${counts.left} for 1, ${counts.right} for 2, ${counts.tie} tie (${counts.voters} voters)`;++ await line(row, `Bench vote closed: ${outcome === 'left' ? '1 wins' : outcome === 'right' ? '2 wins' : 'tie'} — ${summary}.`, 'closed');++}++++async function vote({ streamId, userId, choice }) {++ if (!Number.isSafeInteger(streamId) || !Number.isSafeInteger(userId) || !['left', 'right', 'tie'].includes(choice)) return { ok: false, message: 'Invalid vote.' };++ const stream = await db.getStreamById(streamId);++ if (!stream || !stream.is_live) return { ok: false, message: 'The stream is not live.' };++ if (Number(stream.user_id) === userId) return { ok: false, message: 'The streamer cannot vote in their own round.' };++ if (!await db.getUserById(userId) || !await require('../auth/identity-sync').subjectOf(userId)) return { ok: false, message: 'Sign in to vote.' };++ return await db.tx(async () => {++ // Closing updates this same row. A vote admitted before the deadline commits before the tally runs.++ const row = await db.get("SELECT * FROM live_bench_polls WHERE stream_id = ? AND status = 'open' ORDER BY id DESC LIMIT 1 FOR UPDATE", [streamId]);++ if (!row) return { ok: false, message: 'There is no open Bench vote.' };++ if (new Date(row.closes_at).getTime() <= Date.now()) return { ok: false, message: 'Voting has closed.' };++ const added = await db.run(`INSERT INTO live_bench_votes (poll_id, user_id, choice)++ SELECT ?, ?, ? WHERE EXISTS (SELECT 1 FROM live_bench_polls WHERE id = ? AND status = 'open' AND closes_at > now())++ ON CONFLICT DO NOTHING`, [row.id, userId, choice, row.id]);++ return added.changes ? { ok: true, message: 'Your Bench vote is counted.' } : { ok: false, message: 'You already voted, or voting has closed.' };++ });++}++++function startJobs() {++ require('../utils/jobs').every('bench-stream-polls', 3000, async () => {++ for (const row of await db.all("SELECT * FROM live_bench_polls WHERE status IN ('pending', 'open', 'closing') ORDER BY id LIMIT 30")) {++ try { await advance(row); }++ catch (err) { console.warn('[BenchStream] advance:', err.message); }++ }++ }, { initialDelayMs: 1000 });++}++++module.exports = { start, current, vote, startJobs, publicPoll, advance };@@@ -25,7 +25,7 @@* /internal/chat-effects/* capability live.chat_effects.write — side effects* anon, ip-log, viewer-counts, viewer-snapshots, user-color, ban, approve-ip, channel-settings,* alert-sound, ensure-channel, site-settings, chat-message, ai/mod-command, arena-command,-- * media-queue, hardware, paste, translate, notify/dm, notify/dm-read, notify/call-invite, asset-sync++ * media-queue, hardware, bench-vote, paste, translate, notify/dm, notify/dm-read, notify/call-invite, asset-sync** Every effect that acts for a person re-checks that person here (moderator, owner, admin) — Chat* checked first; Live does not take its word for it.@@@ -510,6 +510,18 @@ const CHAT_ROBOT_MS = 600;const HARDWARE_COOLDOWN_MS = 250;const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;const hardwareLast = new Map(); // `${streamer}:${viewer}` → last command ms++effectsRouter.post('/bench-vote', async (req, res) => {++ const streamId = int(req.body?.stream_id);++ const userId = int(req.body?.viewer_user_id);++ const choice = String(req.body?.choice || '');++ try {++ const out = await require('../bench-stream/service').vote({ streamId, userId, choice });++ res.json(out);++ } catch (err) {++ console.warn('[BenchStream] vote:', err.message);++ res.status(503).json({ ok: false, message: 'Bench could not count that vote.' });++ }++});// What a refusal from Bot means, in chat words (Bot's codes, OpenVibe.Bot docs/protocol.md §2).const ROBOT_REFUSALS = {'bot.not_an_operator': 'You cannot drive this robot. Its owner decides who may.',@@@ -770,6 +770,7 @@ app.use('/data/offline', express.static(paths.data('offline'), {// ── API Routes ───────────────────────────────────────────────app.use('/api/auth', authRoutes);++app.use('/api/bench-stream', require('./bench-stream/routes'));// Channel ↔ OpenVibe.Bot robot binding (LIVE_BOT_EMBED, off by default: the route answers 404).app.use('/api/streams', require('./bot/routes'));app.use('/api/streams', streamRoutes);@@@ -1467,6 +1468,7 @@ async function start() {try { require('./ai/easter-egg-job').start(); } catch (e) { console.warn('[AI] easter-egg job not started:', e.message); }// The Rundown's desk replaced the Arena's jobs (the drama desk, the beef listener and judges, 2026-10-19).try { require('./rundown/desk').start(); } catch (e) { console.warn('[Rundown] desk not started:', e.message); }++ try { require('./bench-stream/service').startJobs(); } catch (e) { console.warn('[BenchStream] poll job not started:', e.message); }try { require('./recap/recap').start(); } catch (e) { console.warn('[Recap] job not started:', e.message); }// Continuous audio → stream_timeline_events. Gated behind ai_timeline_enabled// (default off), so starting it is a no-op until switched on in admin.@@@ -57,6 +57,15 @@ function publicUserProfile(u) {'openvibe_coins_balance', 'last_seen', 'is_owner']);}++/** The watch page's Bench poll: local ownership and voter records stay private. */++function publicBenchPoll(row) {++ if (!row) return null;++ const entries = typeof row.entries === 'string' ? JSON.parse(row.entries) : row.entries;++ const voteCounts = typeof row.vote_counts === 'string' ? JSON.parse(row.vote_counts) : row.vote_counts;++ return { round_id: row.round_id, stream_id: Number(row.stream_id), kind_id: row.kind_id, prompt: row.prompt, status: row.status,++ closes_at: row.closes_at, entries: entries || null, vote_counts: voteCounts || null, judgement_id: row.judgement_id || null };++}++// ── Feed items (server/content/feed.js) ───────────────────────────────────────────────────────// Built from Media, Community and Live rows that carry far more than a card needs (file paths,// storage keys, owner subjects, AI state). Copied through an allow-list, so a field a source adds@@@ -110,4 +119,4 @@ function findSecrets(value, path = '$', out = []) {return out;}--module.exports = { ALWAYS_SECRET, omit, publicManagedStream, publicChannel, publicStream, publicUserProfile, publicFeedItem, findSecrets };++module.exports = { ALWAYS_SECRET, omit, publicManagedStream, publicChannel, publicStream, publicUserProfile, publicBenchPoll, publicFeedItem, findSecrets };nnew file mode 100644@@@ -0,0 +1,71 @@++'use strict';++const assert = require('assert');++const db = require('../server/db/database');++const benchClient = require('../server/bench-stream/bench-client');++const delivery = require('../server/chat/chat-delivery');++++const calls = [];++benchClient.client = () => ({++ tasks: { create: async (kind, body, options) => { calls.push(['task', kind, body, options]); return { task: { id: 'btk_test' } }; } },++ rounds: {++ create: async (taskId, body, options) => { calls.push(['round', taskId, body, options]); return { round: { id: 'brd_test' } }; },++ get: async () => ({ round: { status: 'finished', entries: [++ { id: 'bte_first', payload: { reply: 'one' }, error: null },++ { id: 'bte_second', payload: { reply: 'two' }, error: null },++ ] } }),++ },++ judgements: { cast: async (id, body) => { calls.push(['judge', id, body]); return { judgement: { id: 'bju_test' } }; } },++});++delivery.message = async (body) => { calls.push(['line', body.message]); return 1; };++const poll = require('../server/bench-stream/service');++++(async () => {++ await db.initDb();++ await db.run(`INSERT INTO users (id, username, display_name, email, password_hash, role, created_at) OVERRIDING SYSTEM VALUE++ VALUES (31, 'bench_owner', 'Bench Owner', 'bench_owner@test', 'x', 'streamer', '2025-01-01 00:00:00')`);++ await db.run(`INSERT INTO users (id, username, display_name) OVERRIDING SYSTEM VALUE++ VALUES (32, 'bench_one', 'One'), (33, 'bench_two', 'Two'), (34, 'bench_three', 'Three')`);++ for (const id of [31, 32, 33, 34]) {++ await db.run("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (?, 'network', ?, ?)",++ [id, String(id), id === 31 ? 'usr_01J9AAAAAAAAAAAAAAAAAAAAAA' : `usr_01J9AAAAAAAAAAAAAAAAAAAA${id}`]);++ }++ const channel = await db.ensureChannel(31);++ const created = await db.createStream({ user_id: 31, channel_id: channel.id, title: 'Bench live', protocol: 'rtmp' });++ const streamId = Number(created.lastInsertRowid);++ await db.run('UPDATE streams SET is_live = 1 WHERE id = ?', [streamId]);++++ const started = await poll.start(31, { kind: 'chat.reply', prompt: 'Answer warmly', durationSeconds: 30 });++ assert.strictEqual(started.status, 'pending');++ assert.deepStrictEqual(calls.slice(0, 2).map((c) => c[0]), ['task', 'round']);++ assert.strictEqual(calls[0][3].actingSubject, 'usr_01J9AAAAAAAAAAAAAAAAAAAAAA');++ assert.strictEqual(calls[1][3].actingSubject, 'usr_01J9AAAAAAAAAAAAAAAAAAAAAA');++ await assert.rejects(() => poll.start(31, { kind: 'chat.reply', prompt: 'Again' }), /Finish the current/);++++ let row = await db.get('SELECT * FROM live_bench_polls WHERE round_id = ?', ['brd_test']);++ await poll.advance(row);++ row = await db.get('SELECT * FROM live_bench_polls WHERE round_id = ?', ['brd_test']);++ assert.strictEqual(row.status, 'open');++ assert.strictEqual((await poll.current(31)).entries.length, 2);++ assert.ok(calls.some((c) => c[0] === 'line' && /!1, !2 or !tie/.test(c[1])));++++ const votes = await Promise.all([++ poll.vote({ streamId, userId: 32, choice: 'left' }),++ poll.vote({ streamId, userId: 32, choice: 'right' }),++ poll.vote({ streamId, userId: 33, choice: 'left' }),++ poll.vote({ streamId, userId: 34, choice: 'tie' }),++ ]);++ assert.strictEqual(votes.filter((v) => v.ok).length, 3, 'a concurrent duplicate is not counted');++ assert.strictEqual((await poll.vote({ streamId, userId: 31, choice: 'right' })).ok, false);++++ await db.run("UPDATE live_bench_polls SET closes_at = now() - interval '1 second' WHERE id = ?", [row.id]);++ row = await db.get('SELECT * FROM live_bench_polls WHERE id = ?', [row.id]);++ await poll.advance(row);++ const closed = await db.get('SELECT * FROM live_bench_polls WHERE id = ?', [row.id]);++ assert.strictEqual(closed.status, 'closed');++ assert.deepStrictEqual(calls.find((c) => c[0] === 'judge')[2].vote_counts, { left: 2, right: 0, tie: 1, voters: 3 });++ assert.strictEqual(calls.find((c) => c[0] === 'judge')[2].outcome, 'left');++ assert.ok(calls.some((c) => c[0] === 'line' && /1 wins.*3 voters/.test(c[1])));++ await poll.advance(closed);++ assert.strictEqual(calls.filter((c) => c[0] === 'judge').length, 1);++ console.log('✅ Bench stream round: creates task, opens chat vote, closes and records one service judgement');++})().catch((err) => { console.error(err); process.exitCode = 1; });nnew file mode 100644@@@ -0,0 +1,40 @@++'use strict';++const assert = require('assert');++const db = require('../server/db/database');++const identity = require('../server/auth/identity-sync');++const service = require('../server/bench-stream/service');++++const original = { getStreamById: db.getStreamById, get: db.get, getUserById: db.getUserById,++ run: db.run, tx: db.tx, subjectOf: identity.subjectOf };++const votes = new Set();++const poll = { id: 5, stream_id: 17, streamer_id: 2, status: 'open', closes_at: new Date(Date.now() + 60000).toISOString() };++db.getStreamById = async (id) => id === 17 ? { id: 17, user_id: 2, is_live: 1 } : null;++db.get = async (sql) => sql.includes('live_bench_polls') ? poll : null;++db.getUserById = async (id) => id === 3 || id === 4 ? { id } : null;++identity.subjectOf = async (id) => id === 3 ? 'usr_test' : null;++db.tx = async (fn) => await fn();++db.run = async (sql, params) => {++ if (!sql.includes('INSERT INTO live_bench_votes')) throw new Error('unexpected write');++ const key = `${params[0]}:${params[1]}`;++ if (votes.has(key)) return { changes: 0 };++ votes.add(key);++ return { changes: 1 };++};++++(async () => {++ try {++ assert.strictEqual((await service.vote({ streamId: 17, userId: 0, choice: 'left' })).ok, false, 'anonymous viewer');++ assert.strictEqual((await service.vote({ streamId: 17, userId: 2, choice: 'left' })).ok, false, 'streamer');++ assert.strictEqual((await service.vote({ streamId: 17, userId: 4, choice: 'left' })).ok, false, 'no Network identity');++ assert.strictEqual((await service.vote({ streamId: 17, userId: 3, choice: 'left' })).ok, true, 'signed-in viewer');++ assert.strictEqual((await service.vote({ streamId: 17, userId: 3, choice: 'right' })).ok, false, 'second command cannot change vote');++ assert.strictEqual(votes.size, 1);++ poll.closes_at = new Date(Date.now() - 1000).toISOString();++ poll.status = 'closed';++ assert.strictEqual((await service.vote({ streamId: 17, userId: 3, choice: 'tie' })).ok, false, 'late vote');++ console.log('✅ Bench stream votes: signed-in only, streamer excluded, one per viewer, closed deadline');++ } finally {++ Object.assign(db, { getStreamById: original.getStreamById, get: original.get, getUserById: original.getUserById, run: original.run, tx: original.tx });++ identity.subjectOf = original.subjectOf;++ }++})().catch((err) => { console.error(err); process.exitCode = 1; });nnew file mode 100644@@@ -0,0 +1,65 @@++'use strict';++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const vm = require('vm');++const { publicBenchPoll } = require('../server/web/serializers');++++class Element {++ constructor(tag) { this.tagName = tag; this.children = []; this.attrs = {}; this.hidden = true; }++ append(...children) { this.children.push(...children); }++ replaceChildren(...children) { this.children = children; }++ setAttribute(key, value) { this.attrs[key] = value; }++ set innerHTML(value) { throw new Error('Entry content must not become page HTML'); }++}++++(async () => {++ const panel = new Element('section');++ let refresh;++ let poll = { round_id: 'brd_test', stream_id: 17, kind_id: 'design.page', prompt: '<script>prompt</script>', status: 'open',++ closes_at: new Date(Date.now() + 30000).toISOString(), entries: [++ { payload: { html: '<h1>First</h1><script>parent.stolen = true</script>' } },++ { payload: { result: { move: 'Second' }, reply: '<img src=x onerror=alert(1)>' } },++ ] };++ const context = vm.createContext({++ document: { getElementById: () => panel, createElement: (tag) => new Element(tag) },++ currentChannelUsername: 'streamer', currentStreamId: 17, location: { pathname: '/@streamer' },++ api: async () => ({ poll }), setInterval: (fn) => { refresh = fn; },++ });++ vm.runInContext(fs.readFileSync(path.join(__dirname, '../public/js/bench-stream-watch.js'), 'utf8'), context);++ await new Promise(setImmediate);++ assert.strictEqual(panel.hidden, false);++ assert.strictEqual(panel.children[1].textContent, '<script>prompt</script>');++ const entries = panel.children.filter((e) => e.tagName === 'article');++ assert.strictEqual(entries.length, 2);++ const frame = entries[0].children.find((e) => e.tagName === 'iframe');++ assert.strictEqual(frame.attrs.sandbox, '');++ assert.strictEqual(frame.attrs.referrerpolicy, 'no-referrer');++ assert.ok(frame.srcdoc.includes("default-src 'none'; style-src 'unsafe-inline'; img-src data:"));++ assert.ok(entries[1].children.some((e) => e.tagName === 'pre' && e.textContent.includes('Second')));++ assert.ok(entries[1].children.some((e) => e.tagName === 'p' && e.textContent.includes('<img')));++ await refresh();++ assert.strictEqual(panel.children[3], entries[0], 'polling keeps the existing iframe');++++ poll = { ...poll, status: 'closed', vote_counts: { left: 2, right: 1, tie: 0, voters: 3 } };++ await refresh();++ assert.ok(panel.children[2].textContent.includes('3 voters'));++ context.currentStreamId = 18;++ await refresh();++ assert.strictEqual(panel.hidden, true, 'an old stream cannot show its poll on a new stream');++++ let answer;++ context.currentStreamId = 17;++ context.api = () => new Promise((resolve) => { answer = resolve; });++ const pending = refresh();++ context.currentChannelUsername = 'another';++ answer({ poll });++ await pending;++ assert.strictEqual(panel.hidden, true, 'a late response cannot replace another channel');++++ const publicRow = publicBenchPoll({ ...poll, streamer_id: 31, user_id: 32, secret: 'private' });++ assert.strictEqual(publicRow.streamer_id, undefined);++ assert.strictEqual(publicRow.user_id, undefined);++ assert.strictEqual(publicRow.secret, undefined);++ console.log('✅ Bench watch: two entries, sandbox and CSP, safe text, results, stream isolation, private row fields');++})().catch((err) => { console.error(err); process.exitCode = 1; });
@@ -195,3 +195,4 @@ OV_BILLING_PUBLIC_URL=https://billing.openvibe.network
@@ -195,3 +195,4 @@ OV_BILLING_PUBLIC_URL=https://billing.openvibe.network
# Live reads and writes them with its service token (audience openvibe.inventory) acting for the person.
# Live reads and writes them with its service token (audience openvibe.inventory) acting for the person.
#INVENTORY_URL=http://127.0.0.1:5030
#INVENTORY_URL=http://127.0.0.1:5030
# OV_BILLING_TIMEOUT_MS=8000
# OV_BILLING_TIMEOUT_MS=8000
+OV_BENCH_INTERNAL_URL=http://127.0.0.1:5230
new file mode 100644
new file mode 100644
@@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
+# Bench on stream
+
+A streamer starts a round from the Dashboard while live. Live asks Bench to create a public task and round, then waits for two usable entries. The watch page shows both entries. HTML entries use an empty iframe sandbox with a CSP that blocks scripts and network requests, matching Bench's Design HTML rules.
+
+When the entries are ready, Live posts one chat line opening a 15–600 second vote. OpenVibe.Chat handles `!1`, `!2`, and `!tie` and forwards the signed-in viewer's Live user id and stream id through `POST /internal/chat-effects/bench-vote`. Live verifies the user's Network subject, excludes the streamer, and stores one vote per user per poll. A background job closes votes after the deadline and survives a restart because the poll, deadline, and votes are in Live's database.
+
+Live casts a single Bench service judgement with `judge: audience_poll`, the two entry ids, the plurality outcome, and `vote_counts: { left, right, tie, voters }`. The result is a tie when `!tie` leads or the lead is shared. Bench treats a poll with viewers as one Elo game regardless of voter count; a zero-voter poll is recorded without changing ratings. The counts remain on the judgement for audit and the open dataset. Live then posts the result in chat.
+
+Network must grant Live's service principal `bench.task.create`, `bench.round.create`, and `bench.judgement.cast` for audience `openvibe.bench`. Round reads are public. Set `OV_BENCH_INTERNAL_URL` to Bench's internal origin (default `http://127.0.0.1:5230`). Chat's existing `live.chat_effects.write` grant covers the vote effect.
+
+Live passes the streamer's verified Network subject on task and round creation, so the daily per-person quota belongs to the streamer. Bench accepts this ownership header only from `service:live`; the judgement always belongs to the Live service. A pending poll is abandoned if its stream ends before its entries arrive.
new file mode 100644
new file mode 100644
@@ -0,0 +1,24 @@
@@ -0,0 +1,24 @@
+-- phase: expand
+CREATE TABLE live_bench_polls (
+ id bigserial PRIMARY KEY,
+ streamer_id bigint NOT NULL REFERENCES users(id),
+ stream_id bigint NOT NULL REFERENCES streams(id),
+ round_id text NOT NULL UNIQUE,
+ kind_id text NOT NULL,
+ prompt text NOT NULL,
+ status text NOT NULL CHECK (status IN ('pending', 'open', 'closing', 'closed', 'failed')),
+ duration_seconds integer NOT NULL,
+ closes_at timestamptz,
+ entries jsonb,
+ vote_counts jsonb,
+ judgement_id text,
+ created_at timestamptz NOT NULL DEFAULT now()
+);
+CREATE INDEX live_bench_polls_by_streamer ON live_bench_polls (streamer_id, id DESC);
+CREATE UNIQUE INDEX live_bench_one_active_per_streamer ON live_bench_polls (streamer_id) WHERE status IN ('pending', 'open', 'closing');
+CREATE TABLE live_bench_votes (
+ poll_id bigint NOT NULL REFERENCES live_bench_polls(id) ON DELETE CASCADE,
+ user_id bigint NOT NULL REFERENCES users(id),
+ choice text NOT NULL CHECK (choice IN ('left', 'right', 'tie')),
+ PRIMARY KEY (poll_id, user_id)
+);
@@ -24,7 +24,7 @@
@@ -24,7 +24,7 @@
"node-media-server": "^2.7.1",
"node-media-server": "^2.7.1",
"onnxruntime-node": "^1.20.0",
"onnxruntime-node": "^1.20.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.71.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0",
"pg": "^8.23.1",
"pg": "^8.23.1",
"sanitize-html": "^2.17.7",
"sanitize-html": "^2.17.7",
@@ -2660,9 +2660,9 @@
@@ -2660,9 +2660,9 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"node_modules/openvibe-sdk": {
- "version": "0.67.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
- "integrity": "sha512-s7NzjaNcowVcOIiup2z0ze+xsq/qnr++3pHSI8JzirqJPQ5JsBSUF5RkjOHyfk9bRKlmhY0fsNS6x4dsYylyqw==",
+ "version": "0.71.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.71.0",
+ "integrity": "sha512-5cGZrMtY6sUsYDiXTw40gn7Lu4LCJI0UvvcO7oWHPAQ+QNN9QeRd4LC8jT67nq2C7GsgS3KN0aDA+3tV2J4avw==",
"license": "MIT",
"license": "MIT",
"bin": {
"bin": {
"openvibe-download": "bin/openvibe-download.js"
"openvibe-download": "bin/openvibe-download.js"
@@ -43,7 +43,7 @@
@@ -43,7 +43,7 @@
"node-media-server": "^2.7.1",
"node-media-server": "^2.7.1",
"onnxruntime-node": "^1.20.0",
"onnxruntime-node": "^1.20.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.71.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0",
"pg": "^8.23.1",
"pg": "^8.23.1",
"sanitize-html": "^2.17.7",
"sanitize-html": "^2.17.7",
@@ -114,7 +114,8 @@
@@ -114,7 +114,8 @@
"/js/channel-socials.js",
"/js/channel-socials.js",
"/js/channel-chat-resize.js",
"/js/channel-chat-resize.js",
"/js/channel-support.js",
"/js/channel-support.js",
- "/js/app-channel.js"
+ "/js/app-channel.js",
+ "/js/bench-stream-watch.js"
],
],
"stubs": [
"stubs": [
"toggleChannelChat"
"toggleChannelChat"
@@ -240,7 +241,8 @@
@@ -240,7 +241,8 @@
"/js/settings.js",
"/js/settings.js",
"/js/dashboard-moderation.js",
"/js/dashboard-moderation.js",
"/js/powerchat.js",
"/js/powerchat.js",
- "/js/dashboard.js"
+ "/js/dashboard.js",
+ "/js/bench-stream-dashboard.js"
],
],
"stubs": [
"stubs": [
"regenerateDashStreamKey"
"regenerateDashStreamKey"
@@ -8,6 +8,7 @@
@@ -8,6 +8,7 @@
<!-- Live Stream Area (edge-to-edge) -->
<!-- Live Stream Area (edge-to-edge) -->
<div class="ch-live-area" id="ch-live-area" style="display:none">
<div class="ch-live-area" id="ch-live-area" style="display:none">
+ <section class="dash-card" id="ch-bench-stream" aria-label="Bench vote" hidden></section>
<div class="stream-layout">
<div class="stream-layout">
<div class="stream-main">
<div class="stream-main">
<div class="video-container" id="video-container">
<div class="video-container" id="video-container">
@@ -14,7 +14,28 @@
@@ -14,7 +14,28 @@
<button class="ch-tab" role="tab" aria-selected="false" data-dtab="points" onclick="switchDashTab('points', this)"><i class="fa-solid fa-star"></i> Points</button>
<button class="ch-tab" role="tab" aria-selected="false" data-dtab="points" onclick="switchDashTab('points', this)"><i class="fa-solid fa-star"></i> Points</button>
</div>
</div>
- <div class="ch-tab-panel dash-tab-panel active" id="dash-panel-home" role="tabpanel"><div class="dash-grid" id="dash-grid-home"></div></div>
+ <div class="ch-tab-panel dash-tab-panel active" id="dash-panel-home" role="tabpanel"><div class="dash-grid" id="dash-grid-home">
+ <section class="dash-card" id="dash-bench-stream" aria-labelledby="dash-bench-title">
+ <h3 id="dash-bench-title">Bench on stream</h3>
+ <form id="dash-bench-form">
+ <label for="dash-bench-kind">Kind</label>
+ <select class="form-input" id="dash-bench-kind" name="kind" required>
+ <option value="design.page">Design page</option><option value="design.component">Design component</option>
+ <option value="meme.caption">Meme caption</option><option value="chat.reply">Chat reply</option>
+ <option value="games.fun">Games fun</option><option value="vision.pair">Vision pair</option>
+ <option value="codes.review">Codes review</option><option value="surf.desk">Surf desk</option>
+ <option value="speech.tts">Speech TTS</option><option value="translate.ja">Translate Japanese</option>
+ <option value="search.answer">Search answer</option>
+ </select>
+ <label for="dash-bench-prompt">Prompt</label>
+ <textarea class="form-input" id="dash-bench-prompt" name="prompt" maxlength="4000" required></textarea>
+ <label for="dash-bench-duration">Vote time (seconds)</label>
+ <input class="form-input" id="dash-bench-duration" name="duration" type="number" min="15" max="600" value="60" required>
+ <button class="btn" type="submit">Start round</button>
+ </form>
+ <p id="dash-bench-status" role="status"></p>
+ </section>
+ </div></div>
<div class="ch-tab-panel dash-tab-panel" id="dash-panel-chatai" role="tabpanel"><div class="dash-grid" id="dash-grid-chatai"></div></div>
<div class="ch-tab-panel dash-tab-panel" id="dash-panel-chatai" role="tabpanel"><div class="dash-grid" id="dash-grid-chatai"></div></div>
<div class="ch-tab-panel dash-tab-panel" id="dash-panel-moderation" role="tabpanel"><div class="dash-grid" id="dash-grid-moderation"></div></div>
<div class="ch-tab-panel dash-tab-panel" id="dash-panel-moderation" role="tabpanel"><div class="dash-grid" id="dash-grid-moderation"></div></div>
<div class="ch-tab-panel dash-tab-panel" id="dash-panel-controls" role="tabpanel"><div class="dash-grid" id="dash-grid-controls"></div></div>
<div class="ch-tab-panel dash-tab-panel" id="dash-panel-controls" role="tabpanel"><div class="dash-grid" id="dash-grid-controls"></div></div>
new file mode 100644
new file mode 100644
@@ -0,0 +1,33 @@
@@ -0,0 +1,33 @@
+/* The dashboard's Bench round starter. Chat votes are collected by Live after Bench finishes the entries. */
+(function () {
+ const form = document.getElementById('dash-bench-form');
+ const status = document.getElementById('dash-bench-status');
+ if (!form || !status) return;
+ async function refresh() {
+ try {
+ const { poll } = await api('/bench-stream/mine');
+ if (!poll) {
+ status.textContent = '';
+ form.querySelector('button').disabled = false;
+ return;
+ }
+ status.textContent = `Round ${poll.round_id}: ${poll.status}${poll.status === 'open' ? ` — vote !1, !2 or !tie until ${new Date(poll.closes_at).toLocaleTimeString()}` : ''}`;
+ form.querySelector('button').disabled = ['pending', 'open', 'closing'].includes(poll.status);
+ } catch (e) { status.textContent = e.message || 'Could not load Bench.'; }
+ }
+ form.addEventListener('submit', async (event) => {
+ event.preventDefault();
+ const button = form.querySelector('button');
+ button.disabled = true;
+ status.textContent = 'Starting Bench round…';
+ try {
+ const { poll } = await api('/bench-stream/mine', { method: 'POST', body: {
+ kind: form.elements.kind.value, prompt: form.elements.prompt.value,
+ duration_seconds: Number(form.elements.duration.value),
+ } });
+ status.textContent = `Round ${poll.round_id} is generating its entries.`;
+ } catch (e) { status.textContent = e.message || 'Could not start Bench round.'; button.disabled = false; }
+ });
+ refresh();
+ setInterval(() => { if (document.getElementById('page-dashboard')?.classList.contains('active')) refresh(); }, 5000);
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,81 @@
@@ -0,0 +1,81 @@
+/* Plain watch-page side panel; the main session owns the visual treatment. */
+(function () {
+ const panel = document.getElementById('ch-bench-stream');
+ if (!panel) return;
+ let shown = '';
+ function entryView(entry, number) {
+ const box = document.createElement('article');
+ box.className = 'dash-card';
+ const heading = document.createElement('h4');
+ heading.textContent = `Entry ${number}`;
+ box.append(heading);
+ const payload = entry.payload || {};
+ if (typeof payload.html === 'string') {
+ const frame = document.createElement('iframe');
+ frame.title = `Bench entry ${number}`;
+ frame.setAttribute('sandbox', '');
+ frame.setAttribute('referrerpolicy', 'no-referrer');
+ frame.srcdoc = '<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">'
+ + '<meta http-equiv="Content-Security-Policy" content="default-src \'none\'; style-src \'unsafe-inline\'; img-src data:">'
+ + '<style>html,body{margin:0;min-height:100%;background:#fff}img{max-width:100%;height:auto}</style>' + payload.html;
+ box.append(frame);
+ } else {
+ for (const [key, value] of Object.entries(payload)) {
+ if (typeof value !== 'string') {
+ if (value == null) continue;
+ const pre = document.createElement('pre');
+ pre.textContent = `${key}: ${JSON.stringify(value, null, 2)}`;
+ box.append(pre);
+ continue;
+ }
+ if (key === 'image' && (/^https:\/\//i.test(value) || /^data:image\/(?:png|jpeg|gif|webp);base64,/i.test(value))) {
+ const img = document.createElement('img');
+ img.src = value;
+ img.alt = `Bench entry ${number}`;
+ img.loading = 'lazy';
+ box.append(img);
+ continue;
+ }
+ if (key === 'audio_url' && /^https:\/\//i.test(value)) {
+ const audio = document.createElement('audio');
+ audio.controls = true;
+ audio.src = value;
+ box.append(audio);
+ continue;
+ }
+ const p = document.createElement('p');
+ p.textContent = `${key}: ${value}`;
+ box.append(p);
+ }
+ }
+ return box;
+ }
+ async function refresh() {
+ const username = typeof currentChannelUsername === 'string' ? currentChannelUsername : null;
+ if (!username || !location.pathname.startsWith('/@')) return;
+ try {
+ const { poll } = await api(`/bench-stream/channel/${encodeURIComponent(username)}`);
+ if (username !== currentChannelUsername || !location.pathname.startsWith('/@')) return;
+ if (!poll || poll.status === 'failed' || (typeof currentStreamId !== 'undefined' && Number(currentStreamId) !== poll.stream_id)) {
+ panel.hidden = true; shown = ''; return;
+ }
+ const key = `${poll.round_id}:${poll.status}`;
+ if (shown === key) return;
+ shown = key;
+ panel.replaceChildren();
+ const title = document.createElement('h3');
+ title.textContent = `Bench: ${poll.kind_id}`;
+ const prompt = document.createElement('p');
+ prompt.textContent = poll.prompt;
+ const state = document.createElement('p');
+ state.textContent = poll.status === 'open' ? `Vote in chat: !1, !2 or !tie. Closes ${new Date(poll.closes_at).toLocaleTimeString()}.`
+ : poll.status === 'closed' ? `Result: ${poll.vote_counts.left} for 1, ${poll.vote_counts.right} for 2, ${poll.vote_counts.tie} ties (${poll.vote_counts.voters} voters).`
+ : 'Entries are generating…';
+ panel.append(title, prompt, state);
+ if (poll.entries) for (const [i, entry] of poll.entries.entries()) panel.append(entryView(entry, i + 1));
+ panel.hidden = false;
+ } catch { /* Chat and the stream stay usable while Bench is unavailable. */ }
+ }
+ refresh();
+ setInterval(refresh, 3000);
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,22 @@
@@ -0,0 +1,22 @@
+'use strict';
+
+const { createClient } = require('openvibe-sdk/core');
+const { createBenchClient } = require('openvibe-sdk/bench');
+const principal = require('../net/network-principal');
+
+let bench;
+function client() {
+ if (require('../drill').enabled) throw new Error('Bench is unavailable in a restore drill');
+ if (!principal.configured()) throw new Error('Live has no service credentials for Bench');
+ if (!bench) {
+ const baseUrl = (process.env.OV_BENCH_INTERNAL_URL || 'http://127.0.0.1:5230').replace(/\/+$/, '');
+ bench = createBenchClient(createClient({
+ baseUrls: { bench: baseUrl }, autoDiscover: false, retries: 0,
+ getToken: async () => (await principal.serviceHeaders('openvibe.bench')).Authorization.replace(/^Bearer\s+/i, ''),
+ invalidateToken: () => principal.invalidate('openvibe.bench'),
+ }));
+ }
+ return bench;
+}
+
+module.exports = { client };
new file mode 100644
new file mode 100644
@@ -0,0 +1,38 @@
@@ -0,0 +1,38 @@
+'use strict';
+
+const express = require('express');
+const db = require('../db/database');
+const { requireAuth } = require('../auth/auth');
+const poll = require('./service');
+const router = express.Router();
+
+router.get('/channel/:username', async (req, res) => {
+ res.set('Cache-Control', 'no-store');
+ try {
+ const user = await db.getUserByUsername(req.params.username);
+ if (!user) return res.status(404).json({ error: 'Channel not found' });
+ return res.json({ poll: await poll.current(user.id) });
+ } catch (err) { console.warn('[BenchStream] read:', err.message); return res.status(503).json({ error: 'Bench is unavailable' }); }
+});
+
+router.get('/mine', requireAuth, async (req, res) => {
+ res.set('Cache-Control', 'no-store');
+ try { return res.json({ poll: await poll.current(req.user.id) }); }
+ catch (err) { console.warn('[BenchStream] mine:', err.message); return res.status(503).json({ error: 'Bench is unavailable' }); }
+});
+
+router.post('/mine', requireAuth, async (req, res) => {
+ res.set('Cache-Control', 'no-store');
+ try {
+ const b = req.body || {};
+ return res.status(201).json({ poll: await poll.start(req.user.id, {
+ kind: b.kind, prompt: b.prompt, durationSeconds: b.duration_seconds == null ? 60 : b.duration_seconds,
+ }) });
+ } catch (err) {
+ if (/^(Choose|Write|Vote time|Go live|Finish|Sign in again)/.test(err.message)) return res.status(400).json({ error: err.message });
+ console.warn('[BenchStream] start:', err.message);
+ return res.status(503).json({ error: 'Bench could not start the round' });
+ }
+});
+
+module.exports = router;
new file mode 100644
new file mode 100644
@@ -0,0 +1,122 @@
@@ -0,0 +1,122 @@
+'use strict';
+
+const db = require('../db/database');
+const { client } = require('./bench-client');
+const delivery = require('../chat/chat-delivery');
+const { publicBenchPoll: publicPoll } = require('../web/serializers');
+
+const VALID_KINDS = new Set(['design.page', 'design.component', 'games.fun', 'meme.caption', 'vision.pair', 'codes.review', 'surf.desk', 'chat.reply', 'speech.tts', 'translate.ja', 'search.answer']);
+
+async function line(row, message, suffix) {
+ await delivery.message({ stream_id: Number(row.stream_id), channel_user_id: Number(row.streamer_id), user_id: null,
+ username: 'OpenVibe.Bench', message, message_type: 'system',
+ metadata: { kind: 'bench_stream', round_id: row.round_id }, key: `bench:${row.round_id}:${suffix}` });
+}
+
+async function start(streamerId, { kind, prompt, durationSeconds = 60 }) {
+ if (!VALID_KINDS.has(kind)) throw new Error('Choose a pairwise Bench kind.');
+ if (typeof prompt !== 'string' || !prompt.trim() || prompt.length > 4000) throw new Error('Write a prompt of at most 4000 characters.');
+ if (!Number.isInteger(durationSeconds) || durationSeconds < 15 || durationSeconds > 600) throw new Error('Vote time must be 15 to 600 seconds.');
+ const streams = await db.getLiveStreamsByUserId(streamerId);
+ const stream = streams && streams[0];
+ if (!stream) throw new Error('Go live before starting a Bench round.');
+ const active = await db.get("SELECT id FROM live_bench_polls WHERE streamer_id = ? AND status IN ('pending', 'open', 'closing') LIMIT 1", [streamerId]);
+ if (active) throw new Error('Finish the current Bench round first.');
+ const subject = await require('../auth/identity-sync').subjectOf(streamerId);
+ if (!subject) throw new Error('Sign in again to start a Bench round.');
+ const task = await client().tasks.create(kind, { prompt: prompt.trim(), public: true }, { actingSubject: subject });
+ const created = await client().rounds.create(task.task.id, {}, { actingSubject: subject });
+ try {
+ await db.run(`INSERT INTO live_bench_polls (streamer_id, stream_id, round_id, kind_id, prompt, status, duration_seconds)
+ VALUES (?, ?, ?, ?, ?, 'pending', ?)`, [streamerId, stream.id, created.round.id, kind, prompt.trim(), durationSeconds]);
+ } catch (err) {
+ if (err.code === '23505') throw new Error('Finish the current Bench round first.');
+ throw err;
+ }
+ return await current(streamerId);
+}
+
+async function current(streamerId) {
+ const row = await db.get(`SELECT p.* FROM live_bench_polls p JOIN streams s ON s.id = p.stream_id
+ WHERE p.streamer_id = ? AND s.is_live = 1 ORDER BY p.id DESC LIMIT 1`, [streamerId]);
+ if (!row) return null;
+ return publicPoll(row);
+}
+
+async function advance(row) {
+ if (row.status === 'pending') {
+ const stream = await db.getStreamById(row.stream_id);
+ if (!stream || !stream.is_live) {
+ await db.run("UPDATE live_bench_polls SET status = 'failed' WHERE id = ? AND status = 'pending'", [row.id]);
+ return;
+ }
+ const { round } = await client().rounds.get(row.round_id);
+ if (round.status === 'failed') {
+ await db.run("UPDATE live_bench_polls SET status = 'failed' WHERE id = ? AND status = 'pending'", [row.id]);
+ return;
+ }
+ const entries = (round.entries || []).filter((e) => !e.error).slice(0, 2);
+ if (round.status !== 'finished' || entries.length !== 2) return;
+ const opened = await db.run(`UPDATE live_bench_polls SET status = 'open', entries = ?, closes_at = now() + duration_seconds * interval '1 second'
+ WHERE id = ? AND status = 'pending'`, [JSON.stringify(entries), row.id]);
+ if (opened.changes) await line(row, `Bench vote is open for ${row.duration_seconds}s: !1, !2 or !tie. ${row.prompt}`.slice(0, 480), 'open');
+ return;
+ }
+ if (row.status !== 'open' && row.status !== 'closing') return;
+ if (row.status === 'open' && new Date(row.closes_at).getTime() > Date.now()) return;
+ if (row.status === 'open') {
+ const claimed = await db.run("UPDATE live_bench_polls SET status = 'closing' WHERE id = ? AND status = 'open'", [row.id]);
+ if (!claimed.changes) return;
+ }
+ const counts = { left: 0, right: 0, tie: 0, voters: 0 };
+ for (const v of await db.all('SELECT choice, COUNT(*)::int AS n FROM live_bench_votes WHERE poll_id = ? GROUP BY choice', [row.id])) {
+ counts[v.choice] = Number(v.n);
+ counts.voters += Number(v.n);
+ }
+ const entries = typeof row.entries === 'string' ? JSON.parse(row.entries) : row.entries;
+ const outcome = counts.left > counts.right && counts.left > counts.tie ? 'left'
+ : counts.right > counts.left && counts.right > counts.tie ? 'right' : 'tie';
+ let judgementId = row.judgement_id;
+ if (!judgementId) {
+ try {
+ const result = await client().judgements.cast(row.round_id, { judge: 'audience_poll', pair: { left: entries[0].id, right: entries[1].id }, outcome, vote_counts: counts });
+ judgementId = result.judgement.id;
+ } catch (err) {
+ if (err.status !== 409 || err.code !== 'bench.already_judged') throw err;
+ // A previous close reached Bench and crashed before saving the receipt. Bench's one-service-per-round rule prevents a duplicate.
+ judgementId = 'already-recorded';
+ }
+ }
+ await db.run("UPDATE live_bench_polls SET status = 'closed', vote_counts = ?, judgement_id = ? WHERE id = ? AND status = 'closing'", [JSON.stringify(counts), judgementId, row.id]);
+ const summary = `${counts.left} for 1, ${counts.right} for 2, ${counts.tie} tie (${counts.voters} voters)`;
+ await line(row, `Bench vote closed: ${outcome === 'left' ? '1 wins' : outcome === 'right' ? '2 wins' : 'tie'} — ${summary}.`, 'closed');
+}
+
+async function vote({ streamId, userId, choice }) {
+ if (!Number.isSafeInteger(streamId) || !Number.isSafeInteger(userId) || !['left', 'right', 'tie'].includes(choice)) return { ok: false, message: 'Invalid vote.' };
+ const stream = await db.getStreamById(streamId);
+ if (!stream || !stream.is_live) return { ok: false, message: 'The stream is not live.' };
+ if (Number(stream.user_id) === userId) return { ok: false, message: 'The streamer cannot vote in their own round.' };
+ if (!await db.getUserById(userId) || !await require('../auth/identity-sync').subjectOf(userId)) return { ok: false, message: 'Sign in to vote.' };
+ return await db.tx(async () => {
+ // Closing updates this same row. A vote admitted before the deadline commits before the tally runs.
+ const row = await db.get("SELECT * FROM live_bench_polls WHERE stream_id = ? AND status = 'open' ORDER BY id DESC LIMIT 1 FOR UPDATE", [streamId]);
+ if (!row) return { ok: false, message: 'There is no open Bench vote.' };
+ if (new Date(row.closes_at).getTime() <= Date.now()) return { ok: false, message: 'Voting has closed.' };
+ const added = await db.run(`INSERT INTO live_bench_votes (poll_id, user_id, choice)
+ SELECT ?, ?, ? WHERE EXISTS (SELECT 1 FROM live_bench_polls WHERE id = ? AND status = 'open' AND closes_at > now())
+ ON CONFLICT DO NOTHING`, [row.id, userId, choice, row.id]);
+ return added.changes ? { ok: true, message: 'Your Bench vote is counted.' } : { ok: false, message: 'You already voted, or voting has closed.' };
+ });
+}
+
+function startJobs() {
+ require('../utils/jobs').every('bench-stream-polls', 3000, async () => {
+ for (const row of await db.all("SELECT * FROM live_bench_polls WHERE status IN ('pending', 'open', 'closing') ORDER BY id LIMIT 30")) {
+ try { await advance(row); }
+ catch (err) { console.warn('[BenchStream] advance:', err.message); }
+ }
+ }, { initialDelayMs: 1000 });
+}
+
+module.exports = { start, current, vote, startJobs, publicPoll, advance };
@@ -25,7 +25,7 @@
@@ -25,7 +25,7 @@
* /internal/chat-effects/* capability live.chat_effects.write — side effects
* /internal/chat-effects/* capability live.chat_effects.write — side effects
* anon, ip-log, viewer-counts, viewer-snapshots, user-color, ban, approve-ip, channel-settings,
* anon, ip-log, viewer-counts, viewer-snapshots, user-color, ban, approve-ip, channel-settings,
* alert-sound, ensure-channel, site-settings, chat-message, ai/mod-command, arena-command,
* alert-sound, ensure-channel, site-settings, chat-message, ai/mod-command, arena-command,
- * media-queue, hardware, paste, translate, notify/dm, notify/dm-read, notify/call-invite, asset-sync
+ * media-queue, hardware, bench-vote, paste, translate, notify/dm, notify/dm-read, notify/call-invite, asset-sync
*
*
* Every effect that acts for a person re-checks that person here (moderator, owner, admin) — Chat
* Every effect that acts for a person re-checks that person here (moderator, owner, admin) — Chat
* checked first; Live does not take its word for it.
* checked first; Live does not take its word for it.
@@ -510,6 +510,18 @@ const CHAT_ROBOT_MS = 600;
@@ -510,6 +510,18 @@ const CHAT_ROBOT_MS = 600;
const HARDWARE_COOLDOWN_MS = 250;
const HARDWARE_COOLDOWN_MS = 250;
const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const hardwareLast = new Map(); // `${streamer}:${viewer}` → last command ms
const hardwareLast = new Map(); // `${streamer}:${viewer}` → last command ms
+effectsRouter.post('/bench-vote', async (req, res) => {
+ const streamId = int(req.body?.stream_id);
+ const userId = int(req.body?.viewer_user_id);
+ const choice = String(req.body?.choice || '');
+ try {
+ const out = await require('../bench-stream/service').vote({ streamId, userId, choice });
+ res.json(out);
+ } catch (err) {
+ console.warn('[BenchStream] vote:', err.message);
+ res.status(503).json({ ok: false, message: 'Bench could not count that vote.' });
+ }
+});
// What a refusal from Bot means, in chat words (Bot's codes, OpenVibe.Bot docs/protocol.md §2).
// What a refusal from Bot means, in chat words (Bot's codes, OpenVibe.Bot docs/protocol.md §2).
const ROBOT_REFUSALS = {
const ROBOT_REFUSALS = {
'bot.not_an_operator': 'You cannot drive this robot. Its owner decides who may.',
'bot.not_an_operator': 'You cannot drive this robot. Its owner decides who may.',
@@ -770,6 +770,7 @@ app.use('/data/offline', express.static(paths.data('offline'), {
@@ -770,6 +770,7 @@ app.use('/data/offline', express.static(paths.data('offline'), {
// ── API Routes ───────────────────────────────────────────────
// ── API Routes ───────────────────────────────────────────────
app.use('/api/auth', authRoutes);
app.use('/api/auth', authRoutes);
+app.use('/api/bench-stream', require('./bench-stream/routes'));
// Channel ↔ OpenVibe.Bot robot binding (LIVE_BOT_EMBED, off by default: the route answers 404).
// Channel ↔ OpenVibe.Bot robot binding (LIVE_BOT_EMBED, off by default: the route answers 404).
app.use('/api/streams', require('./bot/routes'));
app.use('/api/streams', require('./bot/routes'));
app.use('/api/streams', streamRoutes);
app.use('/api/streams', streamRoutes);
@@ -1467,6 +1468,7 @@ async function start() {
@@ -1467,6 +1468,7 @@ async function start() {
try { require('./ai/easter-egg-job').start(); } catch (e) { console.warn('[AI] easter-egg job not started:', e.message); }
try { require('./ai/easter-egg-job').start(); } catch (e) { console.warn('[AI] easter-egg job not started:', e.message); }
// The Rundown's desk replaced the Arena's jobs (the drama desk, the beef listener and judges, 2026-10-19).
// The Rundown's desk replaced the Arena's jobs (the drama desk, the beef listener and judges, 2026-10-19).
try { require('./rundown/desk').start(); } catch (e) { console.warn('[Rundown] desk not started:', e.message); }
try { require('./rundown/desk').start(); } catch (e) { console.warn('[Rundown] desk not started:', e.message); }
+ try { require('./bench-stream/service').startJobs(); } catch (e) { console.warn('[BenchStream] poll job not started:', e.message); }
try { require('./recap/recap').start(); } catch (e) { console.warn('[Recap] job not started:', e.message); }
try { require('./recap/recap').start(); } catch (e) { console.warn('[Recap] job not started:', e.message); }
// Continuous audio → stream_timeline_events. Gated behind ai_timeline_enabled
// Continuous audio → stream_timeline_events. Gated behind ai_timeline_enabled
// (default off), so starting it is a no-op until switched on in admin.
// (default off), so starting it is a no-op until switched on in admin.
@@ -57,6 +57,15 @@ function publicUserProfile(u) {
@@ -57,6 +57,15 @@ function publicUserProfile(u) {
'openvibe_coins_balance', 'last_seen', 'is_owner']);
'openvibe_coins_balance', 'last_seen', 'is_owner']);
}
}
+/** The watch page's Bench poll: local ownership and voter records stay private. */
+function publicBenchPoll(row) {
+ if (!row) return null;
+ const entries = typeof row.entries === 'string' ? JSON.parse(row.entries) : row.entries;
+ const voteCounts = typeof row.vote_counts === 'string' ? JSON.parse(row.vote_counts) : row.vote_counts;
+ return { round_id: row.round_id, stream_id: Number(row.stream_id), kind_id: row.kind_id, prompt: row.prompt, status: row.status,
+ closes_at: row.closes_at, entries: entries || null, vote_counts: voteCounts || null, judgement_id: row.judgement_id || null };
+}
+
// ── Feed items (server/content/feed.js) ───────────────────────────────────────────────────────
// ── Feed items (server/content/feed.js) ───────────────────────────────────────────────────────
// Built from Media, Community and Live rows that carry far more than a card needs (file paths,
// Built from Media, Community and Live rows that carry far more than a card needs (file paths,
// storage keys, owner subjects, AI state). Copied through an allow-list, so a field a source adds
// storage keys, owner subjects, AI state). Copied through an allow-list, so a field a source adds
@@ -110,4 +119,4 @@ function findSecrets(value, path = '$', out = []) {
@@ -110,4 +119,4 @@ function findSecrets(value, path = '$', out = []) {
return out;
return out;
}
}
-module.exports = { ALWAYS_SECRET, omit, publicManagedStream, publicChannel, publicStream, publicUserProfile, publicFeedItem, findSecrets };
+module.exports = { ALWAYS_SECRET, omit, publicManagedStream, publicChannel, publicStream, publicUserProfile, publicBenchPoll, publicFeedItem, findSecrets };
new file mode 100644
new file mode 100644
@@ -0,0 +1,71 @@
@@ -0,0 +1,71 @@
+'use strict';
+const assert = require('assert');
+const db = require('../server/db/database');
+const benchClient = require('../server/bench-stream/bench-client');
+const delivery = require('../server/chat/chat-delivery');
+
+const calls = [];
+benchClient.client = () => ({
+ tasks: { create: async (kind, body, options) => { calls.push(['task', kind, body, options]); return { task: { id: 'btk_test' } }; } },
+ rounds: {
+ create: async (taskId, body, options) => { calls.push(['round', taskId, body, options]); return { round: { id: 'brd_test' } }; },
+ get: async () => ({ round: { status: 'finished', entries: [
+ { id: 'bte_first', payload: { reply: 'one' }, error: null },
+ { id: 'bte_second', payload: { reply: 'two' }, error: null },
+ ] } }),
+ },
+ judgements: { cast: async (id, body) => { calls.push(['judge', id, body]); return { judgement: { id: 'bju_test' } }; } },
+});
+delivery.message = async (body) => { calls.push(['line', body.message]); return 1; };
+const poll = require('../server/bench-stream/service');
+
+(async () => {
+ await db.initDb();
+ await db.run(`INSERT INTO users (id, username, display_name, email, password_hash, role, created_at) OVERRIDING SYSTEM VALUE
+ VALUES (31, 'bench_owner', 'Bench Owner', 'bench_owner@test', 'x', 'streamer', '2025-01-01 00:00:00')`);
+ await db.run(`INSERT INTO users (id, username, display_name) OVERRIDING SYSTEM VALUE
+ VALUES (32, 'bench_one', 'One'), (33, 'bench_two', 'Two'), (34, 'bench_three', 'Three')`);
+ for (const id of [31, 32, 33, 34]) {
+ await db.run("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (?, 'network', ?, ?)",
+ [id, String(id), id === 31 ? 'usr_01J9AAAAAAAAAAAAAAAAAAAAAA' : `usr_01J9AAAAAAAAAAAAAAAAAAAA${id}`]);
+ }
+ const channel = await db.ensureChannel(31);
+ const created = await db.createStream({ user_id: 31, channel_id: channel.id, title: 'Bench live', protocol: 'rtmp' });
+ const streamId = Number(created.lastInsertRowid);
+ await db.run('UPDATE streams SET is_live = 1 WHERE id = ?', [streamId]);
+
+ const started = await poll.start(31, { kind: 'chat.reply', prompt: 'Answer warmly', durationSeconds: 30 });
+ assert.strictEqual(started.status, 'pending');
+ assert.deepStrictEqual(calls.slice(0, 2).map((c) => c[0]), ['task', 'round']);
+ assert.strictEqual(calls[0][3].actingSubject, 'usr_01J9AAAAAAAAAAAAAAAAAAAAAA');
+ assert.strictEqual(calls[1][3].actingSubject, 'usr_01J9AAAAAAAAAAAAAAAAAAAAAA');
+ await assert.rejects(() => poll.start(31, { kind: 'chat.reply', prompt: 'Again' }), /Finish the current/);
+
+ let row = await db.get('SELECT * FROM live_bench_polls WHERE round_id = ?', ['brd_test']);
+ await poll.advance(row);
+ row = await db.get('SELECT * FROM live_bench_polls WHERE round_id = ?', ['brd_test']);
+ assert.strictEqual(row.status, 'open');
+ assert.strictEqual((await poll.current(31)).entries.length, 2);
+ assert.ok(calls.some((c) => c[0] === 'line' && /!1, !2 or !tie/.test(c[1])));
+
+ const votes = await Promise.all([
+ poll.vote({ streamId, userId: 32, choice: 'left' }),
+ poll.vote({ streamId, userId: 32, choice: 'right' }),
+ poll.vote({ streamId, userId: 33, choice: 'left' }),
+ poll.vote({ streamId, userId: 34, choice: 'tie' }),
+ ]);
+ assert.strictEqual(votes.filter((v) => v.ok).length, 3, 'a concurrent duplicate is not counted');
+ assert.strictEqual((await poll.vote({ streamId, userId: 31, choice: 'right' })).ok, false);
+
+ await db.run("UPDATE live_bench_polls SET closes_at = now() - interval '1 second' WHERE id = ?", [row.id]);
+ row = await db.get('SELECT * FROM live_bench_polls WHERE id = ?', [row.id]);
+ await poll.advance(row);
+ const closed = await db.get('SELECT * FROM live_bench_polls WHERE id = ?', [row.id]);
+ assert.strictEqual(closed.status, 'closed');
+ assert.deepStrictEqual(calls.find((c) => c[0] === 'judge')[2].vote_counts, { left: 2, right: 0, tie: 1, voters: 3 });
+ assert.strictEqual(calls.find((c) => c[0] === 'judge')[2].outcome, 'left');
+ assert.ok(calls.some((c) => c[0] === 'line' && /1 wins.*3 voters/.test(c[1])));
+ await poll.advance(closed);
+ assert.strictEqual(calls.filter((c) => c[0] === 'judge').length, 1);
+ console.log('✅ Bench stream round: creates task, opens chat vote, closes and records one service judgement');
+})().catch((err) => { console.error(err); process.exitCode = 1; });
new file mode 100644
new file mode 100644
@@ -0,0 +1,40 @@
@@ -0,0 +1,40 @@
+'use strict';
+const assert = require('assert');
+const db = require('../server/db/database');
+const identity = require('../server/auth/identity-sync');
+const service = require('../server/bench-stream/service');
+
+const original = { getStreamById: db.getStreamById, get: db.get, getUserById: db.getUserById,
+ run: db.run, tx: db.tx, subjectOf: identity.subjectOf };
+const votes = new Set();
+const poll = { id: 5, stream_id: 17, streamer_id: 2, status: 'open', closes_at: new Date(Date.now() + 60000).toISOString() };
+db.getStreamById = async (id) => id === 17 ? { id: 17, user_id: 2, is_live: 1 } : null;
+db.get = async (sql) => sql.includes('live_bench_polls') ? poll : null;
+db.getUserById = async (id) => id === 3 || id === 4 ? { id } : null;
+identity.subjectOf = async (id) => id === 3 ? 'usr_test' : null;
+db.tx = async (fn) => await fn();
+db.run = async (sql, params) => {
+ if (!sql.includes('INSERT INTO live_bench_votes')) throw new Error('unexpected write');
+ const key = `${params[0]}:${params[1]}`;
+ if (votes.has(key)) return { changes: 0 };
+ votes.add(key);
+ return { changes: 1 };
+};
+
+(async () => {
+ try {
+ assert.strictEqual((await service.vote({ streamId: 17, userId: 0, choice: 'left' })).ok, false, 'anonymous viewer');
+ assert.strictEqual((await service.vote({ streamId: 17, userId: 2, choice: 'left' })).ok, false, 'streamer');
+ assert.strictEqual((await service.vote({ streamId: 17, userId: 4, choice: 'left' })).ok, false, 'no Network identity');
+ assert.strictEqual((await service.vote({ streamId: 17, userId: 3, choice: 'left' })).ok, true, 'signed-in viewer');
+ assert.strictEqual((await service.vote({ streamId: 17, userId: 3, choice: 'right' })).ok, false, 'second command cannot change vote');
+ assert.strictEqual(votes.size, 1);
+ poll.closes_at = new Date(Date.now() - 1000).toISOString();
+ poll.status = 'closed';
+ assert.strictEqual((await service.vote({ streamId: 17, userId: 3, choice: 'tie' })).ok, false, 'late vote');
+ console.log('✅ Bench stream votes: signed-in only, streamer excluded, one per viewer, closed deadline');
+ } finally {
+ Object.assign(db, { getStreamById: original.getStreamById, get: original.get, getUserById: original.getUserById, run: original.run, tx: original.tx });
+ identity.subjectOf = original.subjectOf;
+ }
+})().catch((err) => { console.error(err); process.exitCode = 1; });
new file mode 100644
new file mode 100644
@@ -0,0 +1,65 @@
@@ -0,0 +1,65 @@
+'use strict';
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const vm = require('vm');
+const { publicBenchPoll } = require('../server/web/serializers');
+
+class Element {
+ constructor(tag) { this.tagName = tag; this.children = []; this.attrs = {}; this.hidden = true; }
+ append(...children) { this.children.push(...children); }
+ replaceChildren(...children) { this.children = children; }
+ setAttribute(key, value) { this.attrs[key] = value; }
+ set innerHTML(value) { throw new Error('Entry content must not become page HTML'); }
+}
+
+(async () => {
+ const panel = new Element('section');
+ let refresh;
+ let poll = { round_id: 'brd_test', stream_id: 17, kind_id: 'design.page', prompt: '<script>prompt</script>', status: 'open',
+ closes_at: new Date(Date.now() + 30000).toISOString(), entries: [
+ { payload: { html: '<h1>First</h1><script>parent.stolen = true</script>' } },
+ { payload: { result: { move: 'Second' }, reply: '<img src=x onerror=alert(1)>' } },
+ ] };
+ const context = vm.createContext({
+ document: { getElementById: () => panel, createElement: (tag) => new Element(tag) },
+ currentChannelUsername: 'streamer', currentStreamId: 17, location: { pathname: '/@streamer' },
+ api: async () => ({ poll }), setInterval: (fn) => { refresh = fn; },
+ });
+ vm.runInContext(fs.readFileSync(path.join(__dirname, '../public/js/bench-stream-watch.js'), 'utf8'), context);
+ await new Promise(setImmediate);
+ assert.strictEqual(panel.hidden, false);
+ assert.strictEqual(panel.children[1].textContent, '<script>prompt</script>');
+ const entries = panel.children.filter((e) => e.tagName === 'article');
+ assert.strictEqual(entries.length, 2);
+ const frame = entries[0].children.find((e) => e.tagName === 'iframe');
+ assert.strictEqual(frame.attrs.sandbox, '');
+ assert.strictEqual(frame.attrs.referrerpolicy, 'no-referrer');
+ assert.ok(frame.srcdoc.includes("default-src 'none'; style-src 'unsafe-inline'; img-src data:"));
+ assert.ok(entries[1].children.some((e) => e.tagName === 'pre' && e.textContent.includes('Second')));
+ assert.ok(entries[1].children.some((e) => e.tagName === 'p' && e.textContent.includes('<img')));
+ await refresh();
+ assert.strictEqual(panel.children[3], entries[0], 'polling keeps the existing iframe');
+
+ poll = { ...poll, status: 'closed', vote_counts: { left: 2, right: 1, tie: 0, voters: 3 } };
+ await refresh();
+ assert.ok(panel.children[2].textContent.includes('3 voters'));
+ context.currentStreamId = 18;
+ await refresh();
+ assert.strictEqual(panel.hidden, true, 'an old stream cannot show its poll on a new stream');
+
+ let answer;
+ context.currentStreamId = 17;
+ context.api = () => new Promise((resolve) => { answer = resolve; });
+ const pending = refresh();
+ context.currentChannelUsername = 'another';
+ answer({ poll });
+ await pending;
+ assert.strictEqual(panel.hidden, true, 'a late response cannot replace another channel');
+
+ const publicRow = publicBenchPoll({ ...poll, streamer_id: 31, user_id: 32, secret: 'private' });
+ assert.strictEqual(publicRow.streamer_id, undefined);
+ assert.strictEqual(publicRow.user_id, undefined);
+ assert.strictEqual(publicRow.secret, undefined);
+ console.log('✅ Bench watch: two entries, sandbox and CSP, safe text, results, stream isolation, private row fields');
+})().catch((err) => { console.error(err); process.exitCode = 1; });