openvibers/openvibe.live Commits
Elements stage 1: the streamer's element library, the Viewer Deck and authorised presses (#842)
Changes
@@@ -182,7 +182,7 @@ Minimum `.env`: `BASE_URL`, `JWT_SECRET`, `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL- [docs/restream-branding.md](docs/restream-branding.md) — branding guide for restream channels.<!-- versions:start -->--- openvibe-contracts: v0.408.0--- openvibe-sdk: v0.150.0--- openvibe-shared: v4.211.0++- openvibe-contracts: v0.410.0++- openvibe-sdk: v0.152.0++- openvibe-shared: v4.212.0<!-- versions:end -->@@@ -5,9 +5,9 @@"runtime": "node 22, express, PostgreSQL, node-media-server, mediasoup; no build step","port": 3000,"domain": "openvibe.live (www, ingest, whip)",-- "contracts": "openvibe-contracts v0.408.0",-- "sdk": "openvibe-sdk v0.150.0",-- "shared": "openvibe-shared v4.211.0",++ "contracts": "openvibe-contracts v0.410.0",++ "sdk": "openvibe-sdk v0.152.0",++ "shared": "openvibe-shared v4.212.0","capabilities": ["live.chat_context.read","live.chat_effects.write",nnew file mode 100644@@@ -0,0 +1,58 @@++-- phase: expand++-- Elements, stage 1 (owner ask 2026-11-11): a streamer's library of buttons and controls people++-- make, the store each one keeps, and the presses that authorise a run. The deck is a new surface++-- on the channel page, so it is OFF until the streamer turns it on (channels.deck_enabled); the++-- migration only adds, and the previous release reads every new table as absent.++CREATE TABLE IF NOT EXISTS elements (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ public_id text COLLATE "C" NOT NULL UNIQUE,++ user_id bigint NOT NULL,++ name text COLLATE "C" NOT NULL,++ description text COLLATE "C",++ look jsonb NOT NULL,++ recipe jsonb NOT NULL,++ capabilities jsonb NOT NULL,++ initial_state jsonb NOT NULL DEFAULT '{}'::jsonb,++ mod jsonb,++ sharing text COLLATE "C" NOT NULL DEFAULT 'private',++ active bigint NOT NULL DEFAULT 1,++ position bigint NOT NULL DEFAULT 0,++ revision bigint NOT NULL DEFAULT 1,++ created_at text COLLATE "C" DEFAULT ov_now(),++ updated_at text COLLATE "C" DEFAULT ov_now(),++ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,++ CONSTRAINT elements_public_id_shape CHECK (public_id ~ '^el_[0-9A-HJKMNP-TV-Z]{26}$'),++ CONSTRAINT elements_sharing CHECK (sharing IN ('private', 'link', 'gallery')),++ CONSTRAINT elements_active CHECK (active IN (0, 1))++);++++CREATE INDEX IF NOT EXISTS idx_elements_deck ON elements(user_id, active, position, id);++CREATE INDEX IF NOT EXISTS idx_elements_public ON elements(public_id);++++CREATE TABLE IF NOT EXISTS element_state (++ element_id bigint PRIMARY KEY,++ revision bigint NOT NULL DEFAULT 0,++ values jsonb NOT NULL DEFAULT '{}'::jsonb,++ updated_at text COLLATE "C" DEFAULT ov_now(),++ FOREIGN KEY (element_id) REFERENCES elements(id) ON DELETE CASCADE++);++++CREATE TABLE IF NOT EXISTS element_press_log (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ element_id bigint NOT NULL,++ presser_id bigint,++ source text COLLATE "C" NOT NULL DEFAULT 'press',++ points bigint NOT NULL DEFAULT 0,++ created_at text COLLATE "C" DEFAULT ov_now(),++ FOREIGN KEY (element_id) REFERENCES elements(id) ON DELETE CASCADE,++ CONSTRAINT element_press_source CHECK (source IN ('press', 'chat')),++ CONSTRAINT element_press_points CHECK (points >= 0)++);++++CREATE INDEX IF NOT EXISTS idx_element_press_person ON element_press_log(element_id, presser_id, created_at);++CREATE INDEX IF NOT EXISTS idx_element_press_all ON element_press_log(element_id, created_at);++++-- The owner switch: a channel's Viewer Deck is off until the streamer opens Elements for it.++ALTER TABLE channels ADD COLUMN deck_enabled bigint NOT NULL DEFAULT 0;++ALTER TABLE channels DROP CONSTRAINT IF EXISTS channels_deck_enabled;++ALTER TABLE channels ADD CONSTRAINT channels_deck_enabled CHECK (deck_enabled IN (0, 1));@@@ -24,8 +24,8 @@"multer": "^1.4.5-lts.1","node-media-server": "^2.7.1","onnxruntime-node": "^1.20.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.408.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.150.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.410.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.152.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.212.0","pg": "^8.23.1","sanitize-html": "^2.17.7",@@@ -3373,9 +3373,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.408.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.408.0",-- "integrity": "sha512-Q2XjfuCDyMFd88CIKVwdZKR9IwZsxZB41cFKxaIWVIrmCt8YO5P2zj8beqvBanqpNgOjPB74HTU/0JCMTbyA+Q==",++ "version": "0.410.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.410.0",++ "integrity": "sha512-rmmq2mebXim2fv+sip2YQ0PrrZLxGUhEfe1w+1EFfGdoVvLI5tp0/eavY2sW6/VU+rzs7cQMC/A0NZD5th5wrA==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -3389,9 +3389,9 @@}},"node_modules/openvibe-sdk": {-- "version": "0.150.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.150.0",-- "integrity": "sha512-5k2PovDQ2zhYXxLHtNNm1Q3rzPMXLpdh0lIYdl9kfa6D2/X2gYozT5W0CHRCIMAK4UhjbsZaXOSMM8GbkJWsNw==",++ "version": "0.152.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.152.0",++ "integrity": "sha512-/nZbjF9KTJPa3582v60tx238BBDa47tJAmxVkbgSWRReXgMHHGAhV8FGKqFmxPHOxLzvMgVp0Ei4mz4A4jsUvQ==","license": "MIT","bin": {"openvibe-download": "bin/openvibe-download.js"@@@ -44,8 +44,8 @@"multer": "^1.4.5-lts.1","node-media-server": "^2.7.1","onnxruntime-node": "^1.20.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.408.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.150.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.410.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.152.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.212.0","pg": "^8.23.1","sanitize-html": "^2.17.7",@@@ -74,6 +74,50 @@display: flex; flex-direction: column;}++/* ── The Viewer Deck (Elements, stage 1) ───────────────────────────────────────++ The streamer's active elements, on the channel page. On a computer it is the column between the player and++ the chat; below 1180 px the page is one column, so it sits under the player. Plain kit buttons; the design of++ the deck belongs to the main session. */++.ch-deck {++ flex: 0 0 300px;++ min-width: 0;++ min-height: 0;++ overflow-y: auto;++ padding: 14px 12px 18px;++ border-left: 1px solid var(--border, rgba(255, 255, 255, 0.08));++ background: var(--bg-card, #111827);++}++.ch-deck[hidden] { display: none !important; }++.ch-deck-title {++ margin: 0 0 10px;++ font-size: 0.82rem;++ font-weight: 800;++ letter-spacing: 0.02em;++ color: var(--text-secondary, #a8b3c4);++}++.ch-deck-list { display: grid; gap: 8px; }++.ch-deck-item { display: grid; gap: 6px; min-width: 0; }++.ch-deck-btn { width: 100%; min-height: 44px; justify-content: space-between; text-align: left; gap: 8px; }++.ch-deck-btn[disabled] { opacity: 0.55; cursor: not-allowed; }++.ch-deck-meta, .ch-deck-note, .ch-deck-state {++ font-size: 0.75rem;++ line-height: 1.35;++ color: var(--text-secondary, #a8b3c4);++ overflow-wrap: anywhere;++}++.ch-deck-note { color: var(--warning-text, var(--warning, #f39c12)); }++.ch-deck-state { font-variant-numeric: tabular-nums; }++@media (max-width: 1180px) {++ .ch-deck {++ flex: none;++ width: 100%;++ border-left: 0;++ border-top: 1px solid var(--border, rgba(255, 255, 255, 0.08));++ padding: 10px 12px;++ }++ .ch-deck-list { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); }++}++.stream-nsfw-gate-card {width: min(100%, 620px);display: flex;@@@ -155,6 +155,7 @@"/js/channel-support.js","/js/card-preview.js","/js/app-channel.js",++ "/js/viewer-deck.js","/js/bench-stream-watch.js","/js/agent-cards.js"],@@@ -186,6 +186,10 @@<!-- Weather spans the full width under the info bar (left-aligned) --><div class="ch-weather-widget ch-weather-full" id="ch-weather-widget" style="display:none"></div></div>++ <!-- The Viewer Deck (Elements): the streamer's active elements. Filled by /js/viewer-deck.js;++ beside the player on a computer and under it on a phone (channel.css). Hidden until the++ streamer has switched Elements on and has at least one active element. -->++ <aside class="ch-deck" id="ch-deck" data-surface="live.deck" aria-label="Deck" hidden></aside><!-- Chat Resize Handle --><!-- Chat Sidebar --><div class="chat-sidebar" id="chat-sidebar">@@@ -315,6 +319,8 @@<!-- Customizable offline screen (image / video / custom HTML) — filled by JS --><div class="ch-offline-screen" id="ch-offline-screen"></div></div><!-- /.stream-main -->++ <!-- The Viewer Deck while the channel is offline: same elements, disabled with the offline note. -->++ <aside class="ch-deck" id="ch-deck-offline" aria-label="Deck" hidden></aside><!-- Streamer's persistent chat (works while offline) — sidebar, mirrors live layout --><div class="offline-global-chat chat-sidebar" id="offline-global-chat"><div class="chat-header">nnew file mode 100644@@@ -0,0 +1,266 @@++'use strict';++/**++ * The Viewer Deck (Elements, stage 1): the streamer's active elements on their channel page.++ *++ * - GET /api/channels/:name/elements the elements, their state and what this viewer may press++ * - POST /api/channels/:name/elements/:id/press++ * - /ws/chat the channel's room for `element-state` frames (the overlay path)++ *++ * The server is the authority: every press re-checks the role, live state, cooldowns, daily caps and++ * channel points, and each product checks its own action again. This file only draws the states the++ * API answers (empty, ready, on cooldown, costs points, not allowed for you, offline, signed out) and++ * keeps the element's own state live. No HTML from the server or the element is inserted as markup.++ */++(function () {++ var containers = [];++ var state = {++ username: null,++ data: null,++ inflight: Object.create(null),++ cooldowns: Object.create(null),++ ws: null,++ wsTimer: null,++ syncTimer: null,++ staleTimer: null,++ lastLoad: 0,++ };++++ function byId(id) { return document.getElementById(id); }++ function node(tag, cls, text) {++ var n = document.createElement(tag);++ if (cls) n.className = cls;++ if (text !== undefined && text !== null) n.textContent = String(text);++ return n;++ }++ function active() {++ var page = byId('page-channel');++ return !!(page && page.classList.contains('active'));++ }++ function username() {++ return typeof currentChannelUsername === 'string' && currentChannelUsername ? currentChannelUsername : null;++ }++ function call(path, opts) {++ if (typeof api !== 'function') return Promise.reject(new Error('The page is not ready.'));++ return api(path, opts);++ }++ function points(n) {++ var v = Number(n) || 0;++ return v.toLocaleString() + (v === 1 ? ' point' : ' points');++ }++ function mmss(ms) {++ var s = Math.max(0, Math.ceil(ms / 1000));++ return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0');++ }++ function stateText(values) {++ var parts = [];++ Object.keys(values || {}).sort().slice(0, 6).forEach(function (k) {++ var v = values[k];++ if (v === null || v === undefined) return;++ if (Array.isArray(v)) v = v.join(', ');++ parts.push(k.replace(/_/g, ' ') + ': ' + String(v));++ });++ return parts.join(' · ');++ }++++ function push(container, title, deck) {++ container.replaceChildren();++ container.hidden = false;++ var h = node('h2', 'ch-deck-title', title);++ container.append(h);++ if (!deck.elements.length) return;++ var list = node('div', 'ch-deck-list');++ deck.elements.forEach(function (el) {++ var item = node('div', 'ch-deck-item');++ item.dataset.eid = el.id;++ var btn = node('button', 'ovk-btn ovk-btn--primary ch-deck-btn');++ btn.type = 'button';++ btn.dataset.eid = el.id;++ btn.append(node('span', 'ch-deck-label', el.look && el.look.label ? el.look.label : el.name));++ var meta = node('span', 'ch-deck-meta');++ btn.append(meta);++ var left = state.cooldowns[el.id] || 0;++ var cost = Number(el.cost_points) || 0;++ var bits = [];++ if (cost) bits.push(points(cost));++ if (deck.viewer && deck.viewer.signed_in && deck.viewer.points !== null && cost) bits.push('you have ' + points(deck.viewer.points));++ if (left > 0) bits.push('on cooldown ' + mmss(left));++ meta.textContent = bits.join(' · ');++ if (!deck.live) {++ btn.disabled = true;++ meta.textContent = 'While ' + (deck.channel.display_name || deck.channel.username) + ' is live';++ } else if (!el.can_press) {++ btn.disabled = true;++ if (!bits.length) meta.textContent = el.message || 'Not available';++ } else if (state.inflight[el.id]) {++ btn.disabled = true;++ meta.textContent = 'Pressing…';++ }++ btn.addEventListener('click', function () { press(el.id); });++ item.append(btn);++ if (el.message && !el.can_press && deck.live) item.append(node('p', 'ch-deck-note', el.message));++ var values = el.state && el.state.values ? el.state.values : {};++ var text = stateText(values);++ if (text) item.append(node('p', 'ch-deck-state', text));++ list.append(item);++ });++ container.append(list);++ if (!deck.enabled) {++ container.append(node('p', 'ch-deck-note', 'Elements are off for this channel.'));++ }++ }++++ function render() {++ containers = [byId('ch-deck'), byId('ch-deck-offline')].filter(Boolean);++ if (!containers.length) return;++ var deck = state.data;++ if (!deck || !deck.enabled || !deck.elements.length) {++ containers.forEach(function (c) { c.hidden = true; c.replaceChildren(); });++ return;++ }++ var title = (deck.channel.display_name || deck.channel.username) + "'s deck";++ containers.forEach(function (c) { push(c, title, deck); });++ }++++ function refreshLater(ms) {++ clearTimeout(state.staleTimer);++ state.staleTimer = setTimeout(function () { load(true); }, ms);++ }++++ async function press(id) {++ var deck = state.data;++ if (!deck || !deck.live || state.inflight[id]) return;++ var el = deck.elements.filter(function (x) { return x.id === id; })[0];++ if (!el || !el.can_press) return;++ state.inflight[id] = true;++ render();++ try {++ var out = await call('/channels/' + encodeURIComponent(state.username) + '/elements/' + encodeURIComponent(id) + '/press', { method: 'POST', body: {} });++ if (out && out.state) { el.state = out.state; }++ if (out && out.balance !== undefined && deck.viewer) deck.viewer.points = out.balance;++ if (out && out.cooldown_ms) state.cooldowns[id] = Date.now() + Number(out.cooldown_ms);++ state.inflight[id] = false;++ render();++ refreshLater(1200);++ } catch (err) {++ var data = (err && err.data) || {};++ el.message = data.error || (err && err.message) || 'That did not work.';++ el.can_press = false;++ if (data.retry_after_ms) { state.cooldowns[id] = Date.now() + Number(data.retry_after_ms); el.can_press = false; }++ state.inflight[id] = false;++ render();++ refreshLater(data.retry_after_ms ? Math.min(Number(data.retry_after_ms), 15000) : 1500);++ }++ }++++ function updateState(frame) {++ if (!state.data || !frame || !frame.element_id) return;++ if (frame.channel && frame.channel.username && frame.channel.username.toLowerCase() !== String(state.username).toLowerCase()) return;++ var el = state.data.elements.filter(function (x) { return x.id === frame.element_id; })[0];++ if (!el) return;++ var current = el.state && Number(el.state.revision) || 0;++ if (Number(frame.revision) < current) return;++ el.state = { revision: Number(frame.revision) || 0, values: frame.values || {} };++ containers = [byId('ch-deck'), byId('ch-deck-offline')].filter(Boolean);++ containers.forEach(function (c) {++ var item = c.querySelector('[data-eid="' + frame.element_id + '"]');++ if (!item) return;++ var line = item.querySelector('.ch-deck-state');++ var text = stateText(el.state.values);++ if (!line) { line = node('p', 'ch-deck-state'); item.append(line); }++ line.textContent = text;++ });++ }++++ function closeSocket() {++ if (state.ws) { try { state.ws.close(); } catch (e) { /* already closed */ } state.ws = null; }++ clearTimeout(state.wsTimer);++ }++++ function connect(deck) {++ closeSocket();++ if (!deck || !deck.live || !deck.stream_id || !deck.elements.length) return;++ var proto = location.protocol === 'https:' ? 'wss' : 'ws';++ var ws;++ try { ws = new WebSocket(proto + '://' + location.host + '/ws/chat?stream=' + encodeURIComponent(deck.stream_id)); } catch (e) { return; }++ state.ws = ws;++ ws.onopen = function () {++ try { ws.send(JSON.stringify({ type: 'join', streamId: deck.stream_id, channel: state.username })); } catch (e) { /* the join retries with the socket */ }++ };++ ws.onmessage = function (e) {++ try { var m = JSON.parse(e.data); if (m && m.type === 'element-state') updateState(m); } catch (err) { /* not ours */ }++ };++ ws.onclose = function () {++ if (state.ws !== ws) return;++ state.ws = null;++ if (active() && state.username === username()) {++ state.wsTimer = setTimeout(function () { if (state.data) connect(state.data); }, 3000);++ }++ };++ }++++ async function load(fresh) {++ var name = username();++ if (!name || !active()) return;++ if (!fresh && state.username === name && state.data && Date.now() - state.lastLoad < 4000) return;++ state.lastLoad = Date.now();++ try {++ var deck = await call('/channels/' + encodeURIComponent(name) + '/elements');++ if (username() !== name) return; // the page moved to another channel while this was in flight++ state.username = name;++ // Keep a local cooldown the server has not seen yet (a press answered a moment ago).++ (deck.elements || []).forEach(function (el) {++ if (state.cooldowns[el.id] && state.cooldowns[el.id] > Date.now()) el.can_press = false;++ });++ state.data = deck;++ render();++ connect(deck);++ } catch (err) {++ if (err && err.status === 404) { state.data = null; render(); }++ }++ }++++ function sync() {++ if (!active()) return;++ var name = username();++ if (!name) return;++ if (name !== state.username || !state.data) { load(false); return; }++ if (Date.now() - state.lastLoad > 30000) load(true);++ }++++ function tick() {++ var now = Date.now();++ var expired = false;++ Object.keys(state.cooldowns).forEach(function (id) {++ if (state.cooldowns[id] && state.cooldowns[id] <= now) { delete state.cooldowns[id]; expired = true; }++ });++ if (!state.data) return;++ state.data.elements.forEach(function (el) {++ var left = state.cooldowns[el.id] || 0;++ containers = containers.length ? containers : [byId('ch-deck'), byId('ch-deck-offline')].filter(Boolean);++ containers.forEach(function (c) {++ var btn = c.querySelector('[data-eid="' + el.id + '"]');++ if (!btn) return;++ var meta = btn.querySelector('.ch-deck-meta');++ if (meta && left > 0) meta.textContent = 'On cooldown ' + mmss(left);++ if (btn.disabled && !left && state.data.live) {++ var wasCooling = meta && /cooldown/i.test(meta.textContent || '');++ if (wasCooling) { btn.disabled = false; meta.textContent = ''; }++ }++ });++ });++ if (expired) refreshLater(300);++ }++++ function boot() {++ state.syncTimer = setInterval(sync, 1500);++ setInterval(tick, 1000);++ window.addEventListener('hashchange', sync);++ window.addEventListener('popstate', sync);++ window.addEventListener('openvibe-auth-changed', function () { state.data = null; state.username = null; load(true); });++ document.addEventListener('visibilitychange', function () { if (!document.hidden) sync(); });++ sync();++ }++++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);++ else boot();++})();@@@ -74,6 +74,7 @@ html[data-ov-motion=off] .al::after{display: none}sub: { icon: 'fa-star', say: 'subscribed' },raid: { icon: 'fa-users', say: 'raided' },goal: { icon: 'fa-trophy', say: 'Goal reached' },++ element: { icon: 'fa-wand-magic-sparkles', say: 'pressed' },};var queue = [], showing = false, seen = new Set();function el(tag, cls, kids) { var n = document.createElement(tag); if (cls) n.className = cls; (kids || []).forEach(function (k) { if (k != null) n.append(k); }); return n; }@@@ -97,6 +98,7 @@ html[data-ov-motion=off] .al::after{display: none}else { what.append(k.say); if (amt) { what.append(' '); var e2 = el('em'); e2.textContent = amt; what.append(e2); } }var msg = el('p', 'al-msg');if (a.kind === 'tip' && a.text) msg.textContent = String(a.text).slice(0, 200);++ if (a.kind === 'element' && a.text) msg.textContent = String(a.text).slice(0, 80);var t = el('div', 'al-t', [el('p', 'al-who', [name]), what, msg]);return el('div', 'al', [ic, t]);}@@@ -73,4 +73,34 @@ async function post(robotId, route, subject, body) {const command = (robotId, subject, { id, kind, value, ms }) => post(robotId, 'commands', subject, { id, kind, value, ms });const controlTicket = (robotId, subject) => post(robotId, 'control-ticket', subject, {});--module.exports = { command, controlTicket, BotRefusal, BotUnavailable, AUDIENCE };++// The role a person holds on a robot, for the element deck's operator-only buttons. Bot answers the++// service that may control the robot (the same capability as a command); a miss or an unreachable Bot is++// "no role", because the command itself still asks Bot and Bot decides. Cached 30 seconds per person.++const ROLE_TTL_MS = 30 * 1000;++const _roles = new Map();++++async function role(robotId, subject) {++ if (!robotId || !subject) return null;++ const key = `${robotId}|${subject}`;++ const hit = _roles.get(key);++ if (hit && Date.now() - hit.at < ROLE_TTL_MS) return hit.role;++ if (drill()) return null;++ const tokens = serviceTokens();++ if (!tokens) return null;++ let res;++ try {++ res = await fetch(`${baseUrl()}/api/v1/robots/${encodeURIComponent(robotId)}/role?subject=${encodeURIComponent(subject)}`, {++ headers: { Accept: 'application/json', Authorization: `Bearer ${await tokens.getToken()}` },++ signal: AbortSignal.timeout(TIMEOUT_MS),++ });++ } catch (err) { note(err); return null; }++ if (res.status === 401) { tokens.invalidate && tokens.invalidate(); return null; }++ if (!res.ok) return null;++ const out = await res.json().catch(() => ({}));++ const value = out && typeof out.role === 'string' ? out.role : null;++ _roles.set(key, { role: value, at: Date.now() });++ if (_roles.size > 5000) _roles.clear();++ return value;++}++++module.exports = { command, controlTicket, role, BotRefusal, BotUnavailable, AUDIENCE, _resetRoles: () => _roles.clear() };@@@ -428,6 +428,17 @@ effectsRouter.post('/chat-message', async (req, res) => {}} catch { /* non-critical */ }}++ // A chat command can be an Element trigger (Elements, stage 1): the same run authorises it, with the++ // chatter as the presser. Commands are rare; a non-command returns after one string check.++ if (b.channel_user_id && b.message) {++ try {++ await require('../elements/press').fromChat({++ channelUserId: int(b.channel_user_id),++ user: { id: b.user_id ? int(b.user_id) : null, isMod: !!b.is_mod, isStreamer: !!b.is_streamer },++ text: String(b.message).slice(0, 400),++ });++ } catch (e) { console.warn('[Elements] chat command:', e && e.message); }++ }res.json({ coin });});nnew file mode 100644@@@ -0,0 +1,400 @@++'use strict';++/**++ * Elements, stage 1: one press, authorised by Live and run through openvibe-sdk/elements.++ *++ * A press is never an element's own power. Live checks the viewer's role (owner, channel moderator,++ * VIP or subscriber, follower, everyone), whether the channel is live, the element's cooldowns and++ * daily caps, and its channel-points cost (Live's own ledger, debited atomically); then each action++ * is sent to the product that owns it: Live's screen and stream actions here, Bot's command path++ * for a robot (turns, operators, limits, Stop, the e-stop and owner takeover are Bot's), Chat's++ * ingress for a message. A refusal from any of them stops the run and refunds the points.++ *++ * The press is shown on stream through the existing overlay alert feed with the presser's name,++ * written to the stream's AI timeline as a moment, and announced as live.element.pressed@1. A++ * screen.counter/screen.state action moves the element's own store and Live broadcasts the new++ * state as live.element.state@1 (and as an `element-state` frame in the channel room).++ *++ * Chat commands come in through Chat's existing /internal/chat-effects/chat-message effect; the++ * same run authorises them, with the chatter as the presser.++ */++const db = require('../db/database');++const identity = require('../auth/identity-sync');++const permissions = require('../auth/permissions');++const billing = require('../monetization/billing-actions');++const delivery = require('../chat/chat-delivery');++const overlay = require('../overlay/overlay-alerts');++const bot = require('../bot/bot-client');++const streamEvents = require('../events/stream-events');++const store = require('./store');++++const STATUS = {++ disabled: 404, offline: 409, not_found: 404, not_active: 404,++ capability: 403, role: 403, points: 402, cooldown: 429, daily_cap: 429,++ not_live: 409, robot_turn: 409, chance: 409, action: 409, limits: 500,++ invalid: 400, trigger: 400, signed_out: 401, disabled_action: 409,++};++++const ROBOT_PRESETS = { up: 1, center: 0, down: -1 };++++const refuse = (code, message, extra = {}) => ({ ok: false, code, message, status: STATUS[code] || 400, ...extra });++const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);++++const RANK = { everyone: 0, follower: 1, subscriber: 2, vip: 3, operator: 4, owner: 5 };++++/**++ * The highest role this person holds in this channel, for a condition that needs `need`. The answer is++ * enough to decide that condition (a rank), not a full audit: an everyone-only deck does not ask Chat++ * or Billing anything, and a follower-only one asks them only for a person who does not follow.++ */++async function roleOf(channel, user, hints = {}, need = 'owner') {++ if (!user || !user.id) return { role: null, signedIn: false };++ const needRank = RANK[need] || 0;++ if (Number(user.id) === Number(channel.user_id)) return { role: 'owner', signedIn: true };++ if (hints.isStreamer) return { role: 'owner', signedIn: true };++ if (needRank === 0) return { role: 'everyone', signedIn: true };++ if (hints.isMod) return { role: 'operator', signedIn: true };++ try { if (await permissions.isChannelModerator(user, channel.user_id)) return { role: 'operator', signedIn: true }; } catch { /* Chat may be unreachable; fall through */ }++ // A channel bound to a robot: Bot's own operators are the People Operators the design names. Bot++ // answers this with the control capability, and the command itself is still Bot's decision.++ if (channel.bot_robot_id) {++ try {++ const subject = await identity.subjectOf(user.id);++ const held = subject ? await bot.role(channel.bot_robot_id, subject) : null;++ if (held === 'owner' || held === 'operator') return { role: 'operator', signedIn: true };++ } catch { /* Bot is unreachable; a channel moderator still counts */ }++ }++ // A follower is the cheapest answer, so a follower-only element asks the follows table first. A++ // non-follower then asks Billing/VIP, because a subscriber is above a follower even without Follow.++ if (needRank <= RANK.follower) {++ try {++ const follow = await db.get('SELECT 1 AS yes FROM follows WHERE follower_id = ? AND streamer_id = ?', [user.id, channel.user_id]);++ if (follow) return { role: 'follower', signedIn: true };++ } catch { /* no follows table answer */ }++ }++ if (needRank <= RANK.vip) {++ // Subscriber and VIP: one Billing/VIP answer covers both.++ try {++ const subject = await identity.subjectOf(user.id);++ const creator = await identity.subjectOf(channel.user_id);++ if (subject && creator) {++ const e = await billing.entitlementOf(subject, creator);++ if (e && e.active) return { role: e.via === 'vip' ? 'vip' : 'subscriber', signedIn: true };++ }++ } catch { /* Billing or VIP is unreachable; no extra tier */ }++ }++ return { role: 'everyone', signedIn: true };++}++++/** The public channel facts the pressed event and the overlay carry. */++const channelFacts = (channel) => ({++ username: channel.username,++ display_name: channel.display_name || channel.username,++});++++/** The presser's public name, never an email or an account id. */++const nameOf = (user) => String((user && (user.display_name || user.username)) || 'Someone').slice(0, 120);++++/** The element's cost in channel points, from its recipe's conditions. */++const costOf = (row) => {++ const c = store.conditionsOf(row).cost;++ return c && Number.isInteger(c.points) ? c.points : 0;++};++++/**++ * Send one action to the product that authorises it. Returns { ok, result?, message? }.++ * `changed` is shared with the caller so one broadcast follows a run that moved the store.++ */++async function send({ channel, presser, user, row, element, live, action, pressId, changed }) {++ switch (action.kind) {++ case 'screen.counter':++ case 'screen.state': {++ const next = await store.applyState(row, action);++ if (next.changed) changed.revision = next.revision;++ return { ok: true, result: next };++ }++ case 'screen.alert': {++ const frame = await overlay.emit({++ streamerId: channel.user_id,++ kind: 'element',++ who: { name: nameOf(presser), avatar: presser && presser.avatar_url ? presser.avatar_url : null },++ text: String(action.title).slice(0, 80),++ naturalKey: `${pressId}:alert:${action.title}`,++ });++ return frame ? { ok: true, result: { id: frame.id } } : { ok: true, result: null };++ }++ case 'stream.moment': {++ const at = elapsedSeconds(live);++ const n = await db.addTimelineEvents([{++ stream_id: live.id, user_id: channel.user_id, kind: 'element', start_sec: at,++ text: `${nameOf(presser)} pressed ${element.name}${action.label ? `: ${String(action.label).slice(0, 120)}` : ''}`,++ label: String(action.label || element.name).slice(0, 120),++ }]);++ return n ? { ok: true, result: { at } } : { ok: false, message: 'The timeline did not take it.' };++ }++ case 'chat.message': {++ const id = await delivery.message({++ stream_id: live.id, channel_user_id: channel.user_id, user_id: null,++ username: 'Deck', message: String(action.text).slice(0, 400), message_type: 'system',++ key: `element:${pressId}:chat`,++ });++ return id ? { ok: true, result: { message_id: id } } : { ok: false, message: 'Chat is not available right now.' };++ }++ case 'robot.drive_pulse': {++ const robotId = channel.bot_robot_id;++ if (!robotId) return { ok: false, message: 'This channel has no robot bound.' };++ if (!user || !user.id) return { ok: false, message: 'Sign in to drive the robot.' };++ const subject = await subjectOf(user);++ if (!subject) return { ok: false, message: 'Sign in with OpenVibe.Network to drive the robot.' };++ const speed = action.speed == null ? 1 : Number(action.speed);++ const value = action.direction === 'forward' ? { throttle: speed, steer: 0 }++ : action.direction === 'back' ? { throttle: -speed, steer: 0 }++ : action.direction === 'left' ? { throttle: 0, steer: speed }++ : action.direction === 'right' ? { throttle: 0, steer: -speed }++ : action.direction === 'spin_left' ? { throttle: 0, steer: speed }++ : { throttle: 0, steer: -speed };++ return await robotCommand(robotId, subject, { kind: 'drive', value, ms: action.ms });++ }++ case 'robot.head_preset': {++ const robotId = channel.bot_robot_id;++ if (!robotId) return { ok: false, message: 'This channel has no robot bound.' };++ if (!user || !user.id) return { ok: false, message: 'Sign in to move the robot.' };++ const subject = await subjectOf(user);++ if (!subject) return { ok: false, message: 'Sign in with OpenVibe.Network to move the robot.' };++ const angle = ROBOT_PRESETS[String(action.preset).toLowerCase()];++ if (angle === undefined) return { ok: false, message: `"${String(action.preset).slice(0, 40)}" is not a head preset (up, center or down).` };++ return await robotCommand(robotId, subject, { kind: 'actuator', value: { name: 'head', value: angle } });++ }++ case 'robot.headlight': {++ const robotId = channel.bot_robot_id;++ if (!robotId) return { ok: false, message: 'This channel has no robot bound.' };++ if (!user || !user.id) return { ok: false, message: 'Sign in to use the robot\'s lights.' };++ const subject = await subjectOf(user);++ if (!subject) return { ok: false, message: 'Sign in with OpenVibe.Network to use the robot\'s lights.' };++ // Bot's headlight kind (LOOI headlights, dsh:looi-headlights-1111d) is the authority; before it++ // ships this is Bot's own unknown-command refusal, never a Live-side substitute.++ return await robotCommand(robotId, subject, { kind: 'headlight', value: { on: !!action.on } });++ }++ case 'robot.say': {++ const robotId = channel.bot_robot_id;++ if (!robotId) return { ok: false, message: 'This channel has no robot bound.' };++ if (!user || !user.id) return { ok: false, message: 'Sign in to make the robot speak.' };++ const subject = await subjectOf(user);++ if (!subject) return { ok: false, message: 'Sign in with OpenVibe.Network to make the robot speak.' };++ return await robotCommand(robotId, subject, { kind: 'say', value: { text: String(action.text).slice(0, 300) } });++ }++ default:++ return { ok: false, message: `${action.kind} is not available in stage 1 of Elements.` };++ }++}++++async function robotCommand(robotId, subject, body) {++ try {++ const answer = await bot.command(robotId, subject, { id: `el_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`, ...body });++ return { ok: true, result: answer && answer.result !== undefined ? answer.result : null };++ } catch (e) {++ if (e instanceof bot.BotRefusal) return { ok: false, message: e.message || 'The robot refused it.', code: e.code };++ return { ok: false, message: 'The robot is not reachable right now.' };++ }++}++++const subjectOf = async (user) => {++ if (!user || !user.id) return null;++ try { return await identity.subjectOf(user.id); } catch { return null; }++};++++const elapsedSeconds = (live) => {++ const started = live && live.started_at ? Date.parse(String(live.started_at).includes('T') ? live.started_at : `${String(live.started_at).replace(' ', 'T')}Z`) : NaN;++ return Number.isFinite(started) ? Math.max(0, Math.round((Date.now() - started) / 1000)) : 0;++};++++/**++ * Run one element for one fired trigger. Never throws for a refusal; returns++ * { ok, status, code?, message?, state?, balance?, points_spent?, actions? }.++ */++async function run({ channel, presser, row, element, trigger, source = 'press' }) {++ if (!channel || !Number(channel.deck_enabled)) return refuse('disabled', 'This channel has not opened its deck.');++ const live = (await db.getLiveStreamsByUserId(channel.user_id) || [])[0];++ if (!live || !live.is_live) return refuse('offline', 'The deck runs while the channel is live.');++++ const needed = (store.conditionsOf(row).role) || 'everyone';++ const { role } = await roleOf(channel, presser, trigger && trigger.hints ? trigger.hints : {}, needed);++ const actorName = nameOf(presser);++ const subject = await subjectOf(presser);++ const now = Date.now();++ const pressId = store.newPressKey(element.id, presser && presser.id, now);++ const changed = { revision: 0 };++ const points = costOf(row);++ let spent = 0;++ let balance = null;++++ const stateBefore = Number((await store.stateOf(row)).revision) || 0;++ const limits = store.limitsFor(row, presser && presser.id, { source, points });++ const ctx = {++ now,++ isLive: true,++ element: { id: element.id, name: element.name, capabilities: element.capabilities },++ actor: { subject: subject ? { type: 'user', id: subject } : undefined, role: role || 'everyone', name: actorName },++ limits,++ spend: async ({ points: cost }) => {++ if (!presser || !presser.id) return { ok: false, message: 'Sign in to spend channel points.' };++ const r = await db.applyChannelPoints({ userId: presser.id, streamerId: channel.user_id, delta: -cost, key: pressId, reason: `element:${String(element.name).slice(0, 80)}` });++ if (r.applied || r.replayed) { spent = cost; balance = r.balance; return { ok: true, balance: r.balance }; }++ balance = r.balance;++ return { ok: false, message: `Not enough channel points (${Number(r.balance) || 0} of ${cost}).` };++ },++ refund: async ({ points: cost }) => {++ if (!presser || !presser.id || !cost) return;++ try {++ const r = await db.applyChannelPoints({ userId: presser.id, streamerId: channel.user_id, delta: cost, key: `refund:${pressId}`, reason: `element refund:${String(element.name).slice(0, 70)}` });++ if (r.applied || r.replayed) { spent = 0; balance = r.balance; }++ } catch { /* the ledger decides; the refusal is reported either way */ }++ },++ send: async (action) => await send({ channel, presser, user: presser, row, element, live, action, pressId, changed }),++ };++++ const result = await store.elementsSdk().run(element.recipe, trigger.trigger, ctx);++ if (!result.ok) {++ // A state action that already ran is still true, so viewers get the new store even though the++ // press as a whole was refused and the points went back. The moment itself is not announced.++ try {++ const nowState = await store.stateOf(row);++ if (Number(nowState.revision) > stateBefore) await announceState({ channel, row, element, state: nowState, live });++ } catch { /* the refusal is the answer */ }++ return refuse(result.code, result.message, {++ retry_after_ms: result.retryAfterMs, scope: result.scope, refused_action: result.refusedAction,++ refunded: !!result.refunded, points_spent: result.refunded ? 0 : (Number(result.pointsSpent) || 0),++ });++ }++++ const state = await store.stateOf(row);++ const stateRevision = Number(state.revision) || 0;++ if (stateRevision > stateBefore) {++ await announceState({ channel, row, element, state, live });++ }++ const presserSubject = subject ? { type: 'user', id: subject } : null;++ await announcePress({ channel, row, element, presser, actorName, presserSubject, trigger: trigger.trigger, points: result.pointsSpent || spent, stateRevision, live });++ if (presser && presser.id) {++ try { balance = await db.getChannelPoints(presser.id, channel.user_id); } catch { /* the response can go without it */ }++ }++ return {++ ok: true, status: 200, code: null,++ state: { revision: state.revision, values: state.values },++ points_spent: result.pointsSpent || 0,++ balance: balance == null ? null : Number(balance),++ actions: result.actions.map((a) => ({ kind: a.action.kind, product: a.action.product })),++ cooldown_ms: await store.cooldownLeft(row, presser && presser.id, Date.now()),++ };++}++++/** The overlay moment: "Maticus pressed Make it dance" with the public name only. */++async function announcePress({ channel, row, element, presser, actorName, presserSubject, trigger, points, stateRevision, live }) {++ const at = new Date().toISOString();++ try {++ await overlay.emit({++ streamerId: channel.user_id,++ kind: 'element',++ who: { name: actorName, avatar: presser && presser.avatar_url ? presser.avatar_url : null },++ text: String(element.name).slice(0, 80),++ naturalKey: `${row.public_id}:${at}:${actorName}`,++ });++ } catch { /* the press happened even if the overlay feed is off */ }++ try {++ const atSec = elapsedSeconds(live);++ await db.addTimelineEvents([{++ stream_id: live.id, user_id: channel.user_id, kind: 'element', start_sec: atSec,++ text: `${actorName} pressed ${element.name}${points ? ` (${points} points)` : ''}`,++ label: String(element.name).slice(0, 120),++ }]);++ } catch { /* the timeline is best effort */ }++ if (!presserSubject) return; // the pressed event's presser is a subject; an unlinked account has none++ try {++ streamEvents.init();++ const envelope = {++ event_type: 'live.element.pressed',++ actor: presserSubject,++ subject: { type: 'element', id: row.public_id },++ visibility: 'public',++ priority: 'low',++ payload: {++ channel: channelFacts(channel),++ element_id: row.public_id,++ element_name: element.name,++ presser: presserSubject,++ presser_name: actorName,++ trigger,++ points_spent: Number(points) || 0,++ state_revision: Number(stateRevision) || 0,++ at,++ },++ };++ await db.getDb().tx(async () => { await streamEvents.enqueue(envelope); });++ streamEvents.kick();++ } catch { /* the event is durable when the outbox is on; the press is not repeated for it */ }++}++++/** Broadcast the store to everyone watching, through the same channel room the overlay listens on. */++async function announceState({ channel, row, element, state, live }) {++ const payload = {++ channel: channelFacts(channel), element_id: row.public_id,++ revision: Number(state.revision) || 0, values: state.values, updated_at: new Date().toISOString(),++ };++ try {++ await delivery.event({ kind: 'channel', id: channel.user_id, stream: live && live.id ? live.id : null },++ { type: 'element-state', ...payload }, { key: `element-state:${row.public_id}:${payload.revision}` });++ } catch { /* Chat's ingress may be off in development */ }++ try {++ streamEvents.init();++ await db.getDb().tx(async () => {++ await streamEvents.enqueue({++ event_type: 'live.element.state',++ actor: { type: 'service', id: 'live' },++ subject: { type: 'element', id: row.public_id },++ visibility: 'public', priority: 'low', payload,++ });++ });++ streamEvents.kick();++ } catch { /* durable event only when the outbox is on */ }++}++++/** One press from the deck (POST /api/channels/:name/elements/:id/press). */++async function fromPress({ channel, presser, id }) {++ const found = await store.byPublicId(channel.user_id, id);++ if (!found) return refuse('not_found', 'That element does not exist.');++ if (!found.element.active) return refuse('not_active', 'That element is not on the deck.');++ return await run({++ channel, presser, row: found.row, element: found.element,++ trigger: { trigger: { kind: 'press' }, hints: {} }, source: 'press',++ });++}++++/** A chat command from Chat's existing /internal/chat-effects/chat-message effect. */++async function fromChat({ channelUserId, user, text }) {++ // Stage 1 takes commands from signed-in people only: it needs a subject for points, roles and++ // the pressed event, and a bot's own system line must never trigger another element.++ if (!user || !user.id) return [];++ const command = String(text || '').trim().split(/\s+/)[0].toLowerCase();++ if (!/^![a-z0-9_-]{1,32}$/.test(command)) return [];++ const channel = await db.get('SELECT c.*, u.username, u.display_name FROM channels c JOIN users u ON u.id = c.user_id WHERE c.user_id = ?', [channelUserId]);++ if (!channel || !Number(channel.deck_enabled)) return [];++ const byCommand = await store.chatCommands(channel.user_id);++ const rows = byCommand.get(command) || [];++ if (!rows.length) return [];++ const presser = await db.getUserById(user.id);++ if (!presser) return [];++ const out = [];++ for (const row of rows) {++ const element = store.shape(row, await db.get('SELECT revision, values FROM element_state WHERE element_id = ?', [row.id]));++ try {++ out.push({ element_id: row.public_id, ...(await run({++ channel, presser, row, element,++ trigger: { trigger: { kind: 'chat_command', command }, hints: { isStreamer: !!user.isStreamer, isMod: !!user.isMod } },++ source: 'chat',++ })) });++ } catch (e) {++ out.push({ element_id: row.public_id, ok: false, code: 'error', message: 'The element could not run.' });++ }++ }++ return out;++}++++module.exports = { run, fromPress, fromChat, roleOf, costOf, STATUS };nnew file mode 100644@@@ -0,0 +1,167 @@++'use strict';++/**++ * Elements, stage 1: the API.++ *++ * The streamer's library (signed in):++ * GET /api/me/elements the library and whether the deck is on++ * GET /api/me/elements/settings the deck switch++ * PUT /api/me/elements/settings { enabled } — off until the streamer turns it on++ * POST /api/me/elements create (validated through openvibe-sdk/elements)++ * GET /api/me/elements/:id one element++ * PATCH /api/me/elements/:id name, look, recipe, capabilities, sharing, active, position++ * DELETE /api/me/elements/:id++ *++ * The Viewer Deck on a channel page (public read, per-viewer hints):++ * GET /api/channels/:name/elements the active elements, their state, what this viewer may press++ * POST /api/channels/:name/elements/:id/press press one: role, cooldowns, daily caps, channel points, live++ *++ * Everything the owner saves goes through the SDK's validate(); everything a press does goes through++ * the SDK's run() and then the product that owns each action. No route here moves money.++ */++const express = require('express');++const db = require('../db/database');++const { requireAuth, optionalAuth } = require('../auth/auth');++const store = require('./store');++const press = require('./press');++++const ownerRouter = express.Router();++const channelRouter = express.Router();++++const fail = (res, err) => {++ if (err instanceof store.ElementError) {++ return res.status(err.status).json({ error: err.message, code: err.code, ...(err.errors ? { errors: err.errors } : {}) });++ }++ console.warn('[Elements]', err && err.message);++ return res.status(500).json({ error: 'The element could not be saved right now.', code: 'elements.error' });++};++const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);++++const publicElement = (e) => ({++ id: e.id,++ name: e.name,++ description: e.description,++ look: e.look,++ capabilities: e.capabilities,++ state: e.state,++});++++// ── The streamer's library ───────────────────────────────────────────────────++ownerRouter.get('/', requireAuth, wrap(async (req, res) => {++ const [{ enabled }, elements] = await Promise.all([store.settings(req.user.id), store.list(req.user.id)]);++ res.set('Cache-Control', 'no-store');++ res.json({ enabled, elements: elements.map((e) => ({ ...e, author: { name: req.user.display_name || req.user.username } })) });++}));++++ownerRouter.get('/settings', requireAuth, wrap(async (req, res) => {++ res.set('Cache-Control', 'no-store');++ res.json(await store.settings(req.user.id));++}));++++ownerRouter.put('/settings', requireAuth, wrap(async (req, res) => {++ if (!req.body || typeof req.body.enabled !== 'boolean') return res.status(400).json({ error: 'enabled is true or false', code: 'element.invalid' });++ res.json(await store.setEnabled(req.user.id, req.body.enabled));++}));++++ownerRouter.post('/', requireAuth, wrap(async (req, res) => {++ try {++ const element = await store.create(req.user, req.body);++ res.status(201).json({ element });++ } catch (e) { fail(res, e); }++}));++++ownerRouter.get('/:id', requireAuth, wrap(async (req, res) => {++ const found = await store.byPublicId(req.user.id, req.params.id);++ if (!found) return res.status(404).json({ error: 'That element does not exist.', code: 'element.not_found' });++ res.json({ element: { ...found.element, author: { name: req.user.display_name || req.user.username } } });++}));++++ownerRouter.patch('/:id', requireAuth, wrap(async (req, res) => {++ try {++ const element = await store.update(req.user, req.params.id, req.body);++ res.json({ element });++ } catch (e) { fail(res, e); }++}));++++ownerRouter.delete('/:id', requireAuth, wrap(async (req, res) => {++ try {++ res.json(await store.remove(req.user, req.params.id));++ } catch (e) { fail(res, e); }++}));++++// ── The Viewer Deck ─────────────────────────────────────────────────────────++/** What this viewer may press, without running anything: the deck's disabled states come from here. */++async function deckHints(channel, viewer, elements, live) {++ const sdk = store.elementsSdk();++ const need = elements.reduce((highest, { row }) => {++ const role = store.conditionsOf(row).role || 'everyone';++ return (sdk.ROLE_RANK[role] || 0) > (sdk.ROLE_RANK[highest] || 0) ? role : highest;++ }, 'everyone');++ const role = viewer ? (await press.roleOf(channel, viewer, {}, need)).role : null;++ const viewerRank = role ? (sdk.ROLE_RANK[role] || 0) : 0;++ let balance = null;++ if (viewer) { try { balance = Number(await db.getChannelPoints(viewer.id, channel.user_id)) || 0; } catch { balance = null; } }++ const now = Date.now();++ const out = [];++ for (const { row, element } of elements) {++ const conditions = store.conditionsOf(row);++ const needed = conditions.role || 'everyone';++ const cost = conditions.cost && Number.isInteger(conditions.cost.points) ? conditions.cost.points : 0;++ let reason = null, message = null, retryAfter = 0;++ const cooldown = await store.cooldownLeft(row, viewer && viewer.id, now);++ const today = viewer ? await store.remainingToday(row, viewer.id, now) : { per_person: null, overall: null };++ if (!viewer) { reason = 'sign_in'; message = 'Sign in to press the deck.'; }++ else if (!live) { reason = 'offline'; message = 'The deck runs while the channel is live.'; }++ else if ((sdk.ROLE_RANK[role] || 0) < (sdk.ROLE_RANK[needed] || 0)) { reason = 'role'; message = needed === 'owner' ? 'Only the channel owner can press this.' : `Only ${needed}s can press this.`; }++ else if (cost && balance != null && balance < cost) { reason = 'points'; message = `It costs ${cost} points; you have ${balance}.`; }++ else if (cooldown > 0) { reason = 'cooldown'; message = `On cooldown for ${Math.ceil(cooldown / 1000)} more seconds.`; retryAfter = cooldown; }++ else if (today.per_person === 0 || today.overall === 0) { reason = 'daily_cap'; message = 'Today\'s limit for this element is used up.'; }++ out.push({++ ...publicElement(element),++ cost_points: cost,++ cooldown_ms: cooldown,++ daily_remaining: today,++ can_press: !reason,++ reason,++ message,++ ...(retryAfter ? { retry_after_ms: retryAfter } : {}),++ });++ }++ return { role, balance, elements: out };++}++++channelRouter.get('/:name/elements', optionalAuth, wrap(async (req, res) => {++ const channel = await store.channelByUsername(req.params.name);++ if (!channel) return res.status(404).json({ error: 'Channel not found', code: 'channel.not_found' });++ res.set('Cache-Control', 'no-store');++ const enabled = !!Number(channel.deck_enabled);++ const live = (await db.getLiveStreamsByUserId(channel.user_id) || [])[0] || null;++ const deck = enabled ? await store.deck(channel.user_id) : [];++ const hints = await deckHints(channel, req.user || null, deck, live);++ res.json({++ surface: 'live.deck',++ channel: { username: channel.username, display_name: channel.display_name || channel.username, avatar_url: channel.avatar_url || null },++ enabled,++ live: !!live,++ stream_id: live ? Number(live.id) : null,++ viewer: { signed_in: !!req.user, role: hints.role, points: hints.balance },++ elements: hints.elements,++ });++}));++++channelRouter.post('/:name/elements/:id/press', requireAuth, wrap(async (req, res) => {++ const channel = await store.channelByUsername(req.params.name);++ if (!channel) return res.status(404).json({ error: 'Channel not found', code: 'channel.not_found' });++ const result = await press.fromPress({ channel, presser: req.user, id: req.params.id });++ res.set('Cache-Control', 'no-store');++ if (!result.ok) {++ if (result.retry_after_ms) res.set('Retry-After', String(Math.max(1, Math.ceil(result.retry_after_ms / 1000))));++ return res.status(result.status || 400).json({++ ok: false, error: result.message, code: result.code,++ ...(result.retry_after_ms ? { retry_after_ms: result.retry_after_ms } : {}),++ ...(result.refunded ? { refunded: true } : {}),++ });++ }++ res.json(result);++}));++++module.exports = { ownerRouter, channelRouter, publicElement, deckHints };nnew file mode 100644@@@ -0,0 +1,399 @@++'use strict';++/**++ * Elements, stage 1: the streamer's library, the store each element keeps, and the presses that++ * authorise a run (owner ask 2026-11-11; design in ~/openvibe/research-2026-11-11/elements.md).++ *++ * An element is a look, a recipe (triggers -> conditions -> actions) and the capabilities it++ * declares. This module keeps the rows and validates every save through openvibe-sdk/elements, so++ * the editor, the API and the press path all speak the same language. It never runs an action: the++ * press path (./press.js) asks Live, Bot and Chat, and each of those checks again.++ *++ * list(userId) the owner's library, newest change first++ * create(userId, body) validate through the SDK, then insert (and its empty state)++ * update(userId, publicId, body) validate the merged element; the revision moves by one++ * remove(userId, publicId)++ * forChannel(userId) the channel row a deck belongs to (created on first save)++ * channelByUsername(name) the public channel row a viewer's deck names++ * deck(userId) the active elements in order, with their state++ * enabled(userId) / setEnabled() the owner switch: off until the streamer turns it on++ * chatCommands(userId) the channel's chat_command triggers, cached 30 s++ * stateOf(element) / applyState() the element's own store, through the SDK reducer++ * limitsFor(element, presserId) cooldowns and daily caps from element_press_log++ * recordPress(...) the press-log row the limits read++ */++const crypto = require('crypto');++const contracts = require('openvibe-contracts');++const db = require('../db/database');++const identity = require('../auth/identity-sync');++++const ID_PREFIX = 'el_';++const CACHE_MS = 30 * 1000;++const MAX_LOOK_BYTES = 32 * 1024;++const MAX_RECIPE_BYTES = 48 * 1024;++++class ElementError extends Error {++ constructor(status, code, message, errors) {++ super(message);++ this.name = 'ElementError';++ this.status = status;++ this.code = code;++ if (errors) this.errors = errors;++ }++}++++const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);++const json = (v, d) => {++ if (v == null) return d;++ if (typeof v !== 'string') return v;++ try { return JSON.parse(v); } catch { return d; }++};++const iso = (v) => {++ if (!v) return null;++ const d = new Date(String(v).includes('T') ? String(v) : `${String(v).replace(' ', 'T')}Z`);++ return Number.isNaN(d.getTime()) ? null : d.toISOString();++};++const publicId = () => ID_PREFIX + contracts.ids.ulid();++const nowIso = () => new Date().toISOString();++const dayOf = (ms) => new Date(ms).toISOString().slice(0, 10);++++/** openvibe-sdk/elements, loaded lazily so a release that does not have it cannot stop the site booting. */++let sdk = null;++let sdkError = null;++function elementsSdk() {++ if (sdk) return sdk;++ if (sdkError) throw new ElementError(503, 'elements_unavailable', 'The element runtime is not available on this release.');++ try { sdk = require('openvibe-sdk/elements'); } catch (e) { sdkError = e; throw new ElementError(503, 'elements_unavailable', 'The element runtime is not available on this release.'); }++ return sdk;++}++++// ── Channel and owner ────────────────────────────────────────────────────────++/** The channel a streamer's library belongs to; a person who has never streamed gets one on first save. */++async function forChannel(userId) {++ const channel = await db.getChannelByUserId(userId);++ return channel || await db.ensureChannel(userId);++}++++async function channelByUsername(username) {++ const name = String(username || '').trim().replace(/^@/, '');++ if (!/^[A-Za-z0-9_][A-Za-z0-9_.-]{0,63}$/.test(name)) return null;++ return await db.getChannelByUsername(name);++}++++async function ownerOf(channel) {++ return await db.getUserById(channel.user_id);++}++++// ── Reads ────────────────────────────────────────────────────────────────────++function shape(row, state) {++ const look = json(row.look, {});++ const recipe = json(row.recipe, {});++ const capabilities = json(row.capabilities, []);++ return {++ id: row.public_id,++ revision: Number(row.revision) || 1,++ name: row.name,++ description: row.description || undefined,++ sharing: row.sharing,++ active: !!Number(row.active),++ position: Number(row.position) || 0,++ look,++ recipe,++ capabilities,++ initial_state: json(row.initial_state, {}),++ mod: json(row.mod, undefined),++ state: { revision: Number(state && state.revision) || 0, values: json(state && state.values, {}) },++ created_at: iso(row.created_at),++ updated_at: iso(row.updated_at),++ };++}++++/** One stored element, or null. Owner-scoped: another person's id is the same 404 as a missing one. */++async function byPublicId(userId, id) {++ if (!/^el_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(id))) return null;++ const row = await db.get('SELECT * FROM elements WHERE user_id = ? AND public_id = ?', [userId, String(id)]);++ if (!row) return null;++ const state = await db.get('SELECT revision, values FROM element_state WHERE element_id = ?', [row.id]);++ return { row, state, element: shape(row, state) };++}++++/** The owner's whole library, with each element's state and active order. */++async function list(userId) {++ const rows = await db.all('SELECT * FROM elements WHERE user_id = ? ORDER BY position ASC, id ASC', [userId]);++ if (!rows.length) return [];++ const states = new Map((await db.all(++ 'SELECT element_id, revision, values FROM element_state WHERE element_id = ANY(?)', [rows.map((r) => r.id)],++ )).map((s) => [String(s.element_id), s]));++ return rows.map((r) => shape(r, states.get(String(r.id))));++}++++/** The active elements a viewer's deck shows, in the streamer's order. */++async function deck(userId) {++ const rows = await db.all('SELECT * FROM elements WHERE user_id = ? AND active = 1 ORDER BY position ASC, id ASC', [userId]);++ if (!rows.length) return [];++ const states = new Map((await db.all(++ 'SELECT element_id, revision, values FROM element_state WHERE element_id = ANY(?)', [rows.map((r) => r.id)],++ )).map((s) => [String(s.element_id), s]));++ return rows.map((r) => ({ row: r, element: shape(r, states.get(String(r.id))) }));++}++++// ── The owner switch ─────────────────────────────────────────────────────────++async function settings(userId) {++ const channel = await forChannel(userId);++ return { enabled: !!Number(channel.deck_enabled) };++}++++async function setEnabled(userId, enabled) {++ const channel = await forChannel(userId);++ await db.run('UPDATE channels SET deck_enabled = ? WHERE id = ?', [enabled ? 1 : 0, channel.id]);++ return { enabled: !!enabled };++}++++// ── Validation through the SDK ────────────────────────────────────────────────++async function authorFor(user) {++ const author = { name: String(user.display_name || user.username || 'Someone').slice(0, 80) };++ try { const subject = await identity.subjectOf(user.id); if (subject) author.subject = { type: 'user', id: subject }; } catch { /* the display name is enough */ }++ return author;++}++++function checkSize(name, value, max) {++ const text = JSON.stringify(value);++ if (Buffer.byteLength(text) > max) throw new ElementError(413, 'element.too_large', `${name} is too large (${Math.ceil(max / 1024)} KB at most).`);++}++++/** Validate the element as the SDK sees it; throws a 400 with the plain reasons when it is not valid. */++async function validateInput(user, body, { id, revision } = {}) {++ if (!isObject(body)) throw new ElementError(400, 'element.invalid', 'The element must be a JSON object.');++ const element = {++ name: body.name,++ description: body.description,++ author: await authorFor(user),++ sharing: body.sharing || 'private',++ look: body.look,++ recipe: body.recipe,++ capabilities: body.capabilities,++ initial_state: body.initial_state,++ mod: body.mod,++ };++ if (id) { element.id = id; element.revision = revision; }++ checkSize('The look', element.look, MAX_LOOK_BYTES);++ checkSize('The recipe', element.recipe, MAX_RECIPE_BYTES);++ const result = elementsSdk().validate(element);++ if (!result.valid) throw new ElementError(400, 'element.invalid', 'That element is not valid.', result.errors);++ return element;++}++++// ── Writes ────────────────────────────────────────────────────────────────────++async function create(user, body) {++ const element = await validateInput(user, body);++ const channel = await forChannel(user.id);++ const active = body.active === undefined ? true : !!body.active;++ const position = Number.isInteger(body.position) ? Math.max(0, Math.min(1000, body.position)) : 0;++ const id = publicId();++ const row = await db.get(`INSERT INTO elements++ (public_id, user_id, name, description, look, recipe, capabilities, initial_state, mod, sharing, active, position)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`, [++ id, user.id, element.name, element.description || null,++ JSON.stringify(element.look), JSON.stringify(element.recipe), JSON.stringify(element.capabilities),++ JSON.stringify(element.initial_state || {}), element.mod ? JSON.stringify(element.mod) : null,++ element.sharing, active ? 1 : 0, position,++ ]);++ await db.run('INSERT INTO element_state (element_id, revision, values) VALUES (?, 0, ?)', [row.id, JSON.stringify(element.initial_state || {})]);++ invalidateCommands(user.id);++ return shape(row, { revision: 0, values: JSON.stringify(element.initial_state || {}) });++}++++/** Merge a patch into the stored element, validate the whole thing, then save. */++async function update(user, id, body) {++ if (!isObject(body)) throw new ElementError(400, 'element.invalid', 'The element must be a JSON object.');++ const found = await byPublicId(user.id, id);++ if (!found) throw new ElementError(404, 'element.not_found', 'That element does not exist.');++ const current = found.element;++ const merged = {++ name: body.name !== undefined ? body.name : current.name,++ description: body.description !== undefined ? body.description : current.description,++ sharing: body.sharing !== undefined ? body.sharing : current.sharing,++ look: body.look !== undefined ? body.look : current.look,++ recipe: body.recipe !== undefined ? body.recipe : current.recipe,++ capabilities: body.capabilities !== undefined ? body.capabilities : current.capabilities,++ initial_state: body.initial_state !== undefined ? body.initial_state : current.initial_state,++ mod: body.mod !== undefined ? body.mod : current.mod,++ };++ const element = await validateInput(user, merged, { id: current.id, revision: current.revision });++ const active = body.active === undefined ? (current.active ? 1 : 0) : (body.active ? 1 : 0);++ const position = body.position === undefined ? current.position : Math.max(0, Math.min(1000, Number(body.position) || 0));++ const row = await db.get(`UPDATE elements SET name = ?, description = ?, look = ?, recipe = ?, capabilities = ?,++ initial_state = ?, mod = ?, sharing = ?, active = ?, position = ?, revision = revision + 1, updated_at = ov_now()++ WHERE id = ? AND user_id = ? RETURNING *`, [++ element.name, element.description || null,++ JSON.stringify(element.look), JSON.stringify(element.recipe), JSON.stringify(element.capabilities),++ JSON.stringify(element.initial_state || {}), element.mod ? JSON.stringify(element.mod) : null,++ element.sharing, active, position, found.row.id, user.id,++ ]);++ // A changed initial state is the starting store only while nothing has run; a live counter is never reset.++ if (body.initial_state !== undefined && Number(found.state && found.state.revision) === 0) {++ await db.run('UPDATE element_state SET values = ? WHERE element_id = ?', [JSON.stringify(element.initial_state || {}), found.row.id]);++ }++ invalidateCommands(user.id);++ const state = await db.get('SELECT revision, values FROM element_state WHERE element_id = ?', [found.row.id]);++ return shape(row, state);++}++++async function remove(user, id) {++ const found = await byPublicId(user.id, id);++ if (!found) throw new ElementError(404, 'element.not_found', 'That element does not exist.');++ await db.run('DELETE FROM elements WHERE id = ? AND user_id = ?', [found.row.id, user.id]);++ invalidateCommands(user.id);++ return { removed: true, id: found.row.public_id };++}++++// ── The element's own store ───────────────────────────────────────────────────++async function stateOf(row) {++ const state = await db.get('SELECT revision, values FROM element_state WHERE element_id = ?', [row.id]);++ return { revision: Number(state && state.revision) || 0, values: json(state && state.values, {}) };++}++++/**++ * Apply one screen.counter or screen.state action through the SDK reducer and persist it with an++ * optimistic revision check. Returns the new { revision, values } or throws a refusal.++ */++async function applyState(row, action) {++ for (let attempt = 0; attempt < 2; attempt++) {++ const current = await stateOf(row);++ const next = elementsSdk().reduce(current, [action]);++ if (next.errors && next.errors.length) throw new ElementError(400, 'element.invalid_state', next.errors[0].message, next.errors);++ if (!next.changed) return { revision: current.revision, values: current.values, changed: false };++ const updated = await db.run('UPDATE element_state SET revision = ?, values = ?, updated_at = ov_now() WHERE element_id = ? AND revision = ?',++ [next.revision, JSON.stringify(next.values), row.id, current.revision]);++ if (Number(updated.changes) > 0) return { revision: next.revision, values: next.values, changed: true };++ }++ throw new ElementError(409, 'element.state_conflict', 'The element changed at the same time; try again.');++}++++// ── Limits: cooldowns and daily caps, from the press log ─────────────────────++const LIMIT_COL = { person: 'presser_id = ?', overall: '1 = 1' };++++/** The element's conditions, as the SDK reads them. */++function conditionsOf(row) {++ const recipe = json(row.recipe, {});++ return isObject(recipe.conditions) ? recipe.conditions : {};++}++++function limitsFor(row, presserId, { source = 'press', points = 0 } = {}) {++ const conditions = conditionsOf(row);++ const cooldown = conditions.cooldown || {};++ const cap = conditions.daily_cap || {};++ return {++ async cooldown({ scope, at }) {++ const seconds = Number(scope === 'person' ? cooldown.per_person_seconds : cooldown.overall_seconds) || 0;++ if (!seconds) return 0;++ const where = LIMIT_COL[scope];++ const args = scope === 'person' ? [row.id, presserId] : [row.id];++ const last = await db.get(`SELECT MAX(created_at) AS at FROM element_press_log WHERE element_id = ? AND ${where}`, args);++ const lastMs = last && last.at ? Date.parse(String(last.at).includes('T') ? last.at : `${String(last.at).replace(' ', 'T')}Z`) : 0;++ if (!lastMs) return 0;++ return Math.max(0, lastMs + seconds * 1000 - at);++ },++ async dailyCount({ scope, day }) {++ const max = scope === 'person' ? cap.per_person : cap.overall;++ if (!max) return 0;++ const where = LIMIT_COL[scope];++ const args = scope === 'person' ? [row.id, presserId] : [row.id];++ const start = `${day}T00:00:00.000Z`;++ const rowCount = await db.get(`SELECT COUNT(*) AS n FROM element_press_log WHERE element_id = ? AND ${where} AND created_at >= ?`, [...args, start]);++ return Number(rowCount && rowCount.n) || 0;++ },++ async hit({ at }) {++ await recordPress({ row, presserId, source, points, at });++ },++ };++}++++/**++ * The press-log row: the per-person/overall cooldown evidence, the daily counter and the moment's++ * own date. `at` is milliseconds; the log keeps UTC ISO text like the rest of the schema.++ */++async function recordPress({ row, presserId, source, points, at }) {++ const when = new Date(Number.isFinite(at) ? at : Date.now()).toISOString();++ await db.run('INSERT INTO element_press_log (element_id, presser_id, source, points, created_at) VALUES (?, ?, ?, ?, ?)',++ [row.id, presserId || null, source === 'chat' ? 'chat' : 'press', Math.max(0, Number(points) || 0), when]);++}++++/** How many times this presser (or anyone) may still fire today, for the deck's hint. */++async function remainingToday(row, presserId, at) {++ const cap = conditionsOf(row).daily_cap || {};++ const day = dayOf(at);++ const out = { per_person: null, overall: null };++ if (cap.per_person) {++ const r = await db.get('SELECT COUNT(*) AS n FROM element_press_log WHERE element_id = ? AND presser_id = ? AND created_at >= ?', [row.id, presserId, `${day}T00:00:00.000Z`]);++ out.per_person = Math.max(0, Number(cap.per_person) - (Number(r && r.n) || 0));++ }++ if (cap.overall) {++ const r = await db.get('SELECT COUNT(*) AS n FROM element_press_log WHERE element_id = ? AND created_at >= ?', [row.id, `${day}T00:00:00.000Z`]);++ out.overall = Math.max(0, Number(cap.overall) - (Number(r && r.n) || 0));++ }++ return out;++}++++async function cooldownLeft(row, presserId, at) {++ const cooldown = conditionsOf(row).cooldown || {};++ let left = 0;++ for (const scope of ['person', 'overall']) {++ const seconds = Number(scope === 'person' ? cooldown.per_person_seconds : cooldown.overall_seconds) || 0;++ if (!seconds) continue;++ const args = scope === 'person' ? [row.id, presserId] : [row.id];++ const last = await db.get(`SELECT MAX(created_at) AS at FROM element_press_log WHERE element_id = ? AND ${LIMIT_COL[scope]}`, args);++ const lastMs = last && last.at ? Date.parse(String(last.at).includes('T') ? last.at : `${String(last.at).replace(' ', 'T')}Z`) : 0;++ if (lastMs) left = Math.max(left, lastMs + seconds * 1000 - at);++ }++ return left;++}++++/** A stable key for one press's points debit and its refund. */++const newPressKey = (elementId, presserId, at) => `element:${elementId}:${presserId || 'anon'}:${at}:${crypto.randomBytes(8).toString('hex')}`;++++// ── The chat_command trigger cache ────────────────────────────────────────────++const commandCache = new Map(); // user id -> { at, byCommand: Map<command, element public id> }++++function commandsFor(userId) {++ return commandCache.get(String(userId)) || null;++}++++function invalidateCommands(userId) {++ commandCache.delete(String(userId));++}++++/**++ * The channel's active elements that listen for a chat command, keyed by the command. Cached 30 s++ * and invalidated on every save, so a busy chat does not read the library per message.++ */++async function chatCommands(userId) {++ const key = String(userId);++ const hit = commandCache.get(key);++ if (hit && Date.now() - hit.at < CACHE_MS) return hit.byCommand;++ const rows = await db.all("SELECT * FROM elements WHERE user_id = ? AND active = 1 AND recipe::text LIKE '%chat_command%'", [userId]);++ const byCommand = new Map();++ for (const row of rows) {++ const recipe = json(row.recipe, {});++ for (const trigger of Array.isArray(recipe.triggers) ? recipe.triggers : []) {++ if (trigger && trigger.kind === 'chat_command' && /^![a-z0-9_-]{1,32}$/.test(String(trigger.command))) {++ const command = String(trigger.command).toLowerCase();++ if (!byCommand.has(command)) byCommand.set(command, []);++ byCommand.get(command).push(row);++ }++ }++ }++ commandCache.set(key, { at: Date.now(), byCommand });++ if (commandCache.size > 5000) commandCache.clear();++ return byCommand;++}++++module.exports = {++ ElementError, elementsSdk,++ forChannel, channelByUsername, ownerOf,++ byPublicId, list, deck, shape, json,++ settings, setEnabled,++ create, update, remove,++ stateOf, applyState, conditionsOf, limitsFor, recordPress, remainingToday, cooldownLeft, newPressKey,++ chatCommands, invalidateCommands,++ dayOf,++};@@@ -826,6 +826,7 @@ app.use('/api/auth', authRoutes);// The signed-in person's picture-in-picture layout, in their Network account (server/me/routes.js); 404 until the// pinned contracts carry live.layout, so the browser's own copy keeps working.app.use('/api/me', require('./me/routes').createLayoutRouter());++app.use('/api/me/elements', require('./elements/routes').ownerRouter); // the streamer's element library (Elements, stage 1)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'));@@@ -905,6 +906,7 @@ app.use('/api/ai-viewers', require('./ai/viewers/routes'));app.use('/api/vibe', require('./ai/vibe-routes'));app.use('/api/powerchat', require('./integrations/powerchat-routes'));app.use('/api/overlay', require('./overlay/routes')); // the overlay alert feed: the streamer's own test alert++app.use('/api/channels', require('./elements/routes').channelRouter); // the Viewer Deck and its presses (Elements, stage 1)app.use('/api/meta', metaRoutes);app.use('/api/pastes', pasteRoutes);app.use('/api/content', require('./content/routes')); // Content (people's work) + Moments (AI) feeds@@@ -11,7 +11,7 @@** { type: 'overlay-alert', kind, id, at, who: { name, avatar }|null, amount: { value, unit }|null, text }*-- * kind follow | tip | sub | raid | goal++ * kind follow | tip | sub | raid | goal | element* id unique per moment (for de-duplication on the overlay); a hash of the moment's natural key,* so it says nothing about who tipped, how much, or which account it was* at when the moment happened (ISO 8601)@@@ -33,7 +33,7 @@const crypto = require('crypto');const delivery = require('../chat/chat-delivery');--const KINDS = ['follow', 'tip', 'sub', 'raid', 'goal'];++const KINDS = ['follow', 'tip', 'sub', 'raid', 'goal', 'element'];const UNITS = ['vibes', 'usd', 'viewers'];const TEXT_MAX = 200;const NAME_MAX = 120;@@@ -25,6 +25,7 @@ const SAMPLES = {sub: { who: { name: 'Test Subscriber', avatar: null }, amount: null, text: null },raid: { who: { name: 'Test Raider', avatar: null }, amount: { value: 12, unit: 'viewers' }, text: null },goal: { who: null, amount: { value: 1000, unit: 'vibes' }, text: null },++ element: { who: { name: 'Test Viewer', avatar: null }, amount: null, text: 'Make it dance' },};// A few a minute, per streamer.nnew file mode 100644@@@ -0,0 +1,39 @@++'use strict';++/**++ * The Viewer Deck's page wiring, without a browser: the channel fragment carries the two `live.deck`++ * containers (live and offline), the channel feature loads the deck script, the script speaks to the++ * deck API and the channel's element-state frames, the stylesheet places the deck beside the player on++ * a computer and under it below 1180 px, and the OBS alerts page knows the element moment. The API and++ * the presses themselves are test/elements.test.js; this is the part a server test cannot see.++ */++const assert = require('assert');++const fs = require('fs');++const path = require('path');++++const ROOT = path.join(__dirname, '..');++const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');++++const fragment = read('public/fragments/channel.html');++assert.strictEqual((fragment.match(/<aside class="ch-deck"/g) || []).length, 2, 'a live deck and an offline deck');++assert.strictEqual((fragment.match(/data-surface="live\.deck"/g) || []).length, 1, 'the live deck names the live.deck surface');++assert.match(fragment, /id="ch-deck"/);++assert.match(fragment, /id="ch-deck-offline"/);++++const features = JSON.parse(read('public/features.json'));++assert.ok(features.features.channel.js.includes('/js/viewer-deck.js'), 'the channel feature loads the deck script');++++const deck = read('public/js/viewer-deck.js');++assert.match(deck, /\/channels\/' \+ encodeURIComponent\(/);++assert.match(deck, /element-state/);++assert.match(deck, /ovk-btn/);++assert.match(deck, /cannot|Not available|Sign in|While /);++++const css = read('public/css/features/channel.css');++assert.match(css, /\.ch-deck \{/);++assert.match(css, /@media \(max-width: 1180px\)/);++++const alerts = read('public/obs/alerts.html');++assert.match(alerts, /element: \{ icon:/, 'the OBS alerts page draws an element moment');++++console.log('elements page: the deck markup, script and styles are wired');++process.exit(0);nnew file mode 100644@@@ -0,0 +1,388 @@++'use strict';++/**++ * Elements, stage 1: the library, the deck and every refusal on the press path.++ *++ * node --import ./test/helpers/pg-preload.mjs test/elements.test.js++ *++ * The products are stubs: Bot records its command, Chat records its message, the overlay records its++ * moment. What is real here is Live's own authorisation: the owner switch, live-only, the viewer's++ * role, cooldowns, daily caps, channel points, the declared capabilities, the state reducer and the++ * refund when a product refuses.++ */++const assert = require('assert');++++process.env.NODE_ENV = 'test';++const quiet = console.log;++console.log = () => {};++console.warn = () => {};++++(async () => {++ const db = require('../server/db/database');++ await db.initDb();++ const raw = db.getDb();++++ const addUser = async (id, username, display) => await raw.prepare(++ `INSERT INTO users (id, username, display_name, email, password_hash, role) OVERRIDING SYSTEM VALUE++ VALUES (?, ?, ?, ?, 'x', 'user')`).run(id, username, display, `${username}@example.test`);++ await addUser(2, 'ann', 'Ann');++ await addUser(3, 'bob', 'Bob');++ await addUser(4, 'cid', 'Cid');++ const ANN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNA1';++ const BOB = 'usr_01JAB2C3D4E5F6G7H8J9K0MNB2';++ const CID = 'usr_01JAB2C3D4E5F6G7H8J9K0MNC3';++ await raw.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (2, 'network', '102', ?), (3, 'network', '103', ?), (4, 'network', '104', ?)")++ .run(ANN, BOB, CID);++ const channel = await db.ensureChannel(2);++ const streamId = Number((await raw.prepare("INSERT INTO streams (user_id, channel_id, title, protocol, is_live, started_at) VALUES (2, ?, 'Ann live', 'webrtc', 1, ?) RETURNING id")++ .run(channel.id, new Date(Date.now() - 60_000).toISOString())).lastInsertRowid);++ await raw.prepare("INSERT INTO follows (follower_id, streamer_id) VALUES (3, 2)").run();++ await db.applyChannelPoints({ userId: 3, streamerId: 2, delta: 100, key: 'seed:bob' });++++ // OpenVibe.Bot and OpenVibe.Chat are stubs; Live is real.++ const delivery = require('../server/chat/chat-delivery');++ const overlay = require('../server/overlay/overlay-alerts');++ const bot = require('../server/bot/bot-client');++ const permissions = require('../server/auth/permissions');++ const billing = require('../server/monetization/billing-actions');++ const streamEvents = require('../server/events/stream-events');++ const sent = { chat: [], events: [], alerts: [], bots: [], envelopes: [] };++ delivery.message = async (body) => { sent.chat.push(body); return 900 + sent.chat.length; };++ delivery.event = async (target, frame, opts) => { sent.events.push({ target, frame, opts }); return {}; };++ overlay.emit = async (args) => { sent.alerts.push(args); return { id: `a${sent.alerts.length}` }; };++ bot.command = async (...args) => { sent.bots.push(args); if (sent.botRefuse) throw new bot.BotRefusal('bot.not_an_operator', 'you are not an operator on this robot', 403); return { result: { ok: true } }; };++ bot.role = async (robotId, subject) => (subject === BOB ? 'operator' : null);++ permissions.isChannelModerator = async () => false;++ billing.entitlementOf = async () => ({ active: false, via: 'billing' });++ db.isActiveSubscriber = async () => false;++ streamEvents.enqueue = async (env) => { sent.envelopes.push(env); return env; };++ streamEvents.init = () => null;++ streamEvents.kick = () => {};++++ const store = require('../server/elements/store');++ const press = require('../server/elements/press');++ const ann = { id: 2, username: 'ann', display_name: 'Ann' };++ const bob = { id: 3, username: 'bob', display_name: 'Bob', avatar_url: null };++ const cid = { id: 4, username: 'cid', display_name: 'Cid', avatar_url: null };++ const reset = () => { sent.chat.length = 0; sent.events.length = 0; sent.alerts.length = 0; sent.bots.length = 0; sent.envelopes.length = 0; sent.botRefuse = false; };++++ const look = (label = 'Dance') => ({ component: 'button', label });++ const recipe = (over = {}) => ({++ triggers: [{ kind: 'press' }],++ conditions: {},++ actions: [{ kind: 'screen.counter', product: 'live', key: 'dances', by: 1 }],++ ...over,++ });++ const make = async (over = {}) => await store.create(ann, {++ name: over.name || 'Make it dance',++ look: over.look || look(),++ recipe: over.recipe || recipe(),++ capabilities: over.capabilities || ['screen.counter'],++ initial_state: over.initial_state || { dances: 0 },++ sharing: 'private',++ active: over.active === undefined ? true : over.active,++ position: over.position,++ });++++ // ── The library validates through the SDK ─────────────────────────────────++ {++ const element = await make();++ assert.match(element.id, /^el_/);++ assert.equal(element.revision, 1);++ assert.deepEqual(store.elementsSdk().validate({ name: element.name, author: { name: 'Ann' }, sharing: 'private', look: element.look, recipe: element.recipe, capabilities: element.capabilities }).valid, true);++ await assert.rejects(++ store.create(ann, { name: 'Bad', look: look(), recipe: recipe(), capabilities: ['screen.alert'] }),++ (e) => e.status === 400 && e.errors.some((x) => /did not declare/.test(x.message)),++ 'a recipe action the element did not declare is refused at save',++ );++ await assert.rejects(store.create(ann, { name: 'Bad', look: look(), recipe: recipe({ triggers: [{ kind: 'cheer' }] }), capabilities: ['screen.counter'] }), (e) => e.status === 400);++ const updated = await store.update(ann, element.id, { name: 'Make it dance now', position: 2 });++ assert.equal(updated.name, 'Make it dance now');++ assert.equal(updated.revision, 2);++ const list = await store.list(2);++ assert.ok(list.some((x) => x.id === element.id));++ }++++ // ── The owner switch is off until the streamer turns it on ───────────────++ assert.deepEqual(await store.settings(2), { enabled: false });++ const offDeck = await press.fromPress({ channel: await store.channelByUsername('ann'), presser: bob, id: (await store.list(2))[0].id });++ assert.equal(offDeck.code, 'disabled');++ await store.setEnabled(2, true);++ assert.deepEqual(await store.settings(2), { enabled: true });++++ // ── The deck: active elements only, in order, with their state ───────────++ {++ const second = await make({ name: 'Second', position: 1 });++ const hidden = await make({ name: 'Hidden', position: 2, active: false });++ const deck = await store.deck(2);++ assert.ok(deck.length >= 2);++ const ids = deck.map((x) => x.element.id);++ assert.ok(ids.includes(second.id) && !ids.includes(hidden.id));++ assert.ok(deck.every((x) => x.element.state && typeof x.element.state.values === 'object'));++ }++++ // ── Live-only ────────────────────────────────────────────────────────────++ {++ const element = await make({ name: 'Live only', position: 10 });++ await raw.prepare('UPDATE streams SET is_live = 0, ended_at = ov_now() WHERE id = ?').run(streamId);++ let r = await press.fromPress({ channel: await store.channelByUsername('ann'), presser: bob, id: element.id });++ assert.equal(r.code, 'offline');++ assert.equal(r.status, 409);++ assert.equal(sent.alerts.length, 0);++ await raw.prepare('UPDATE streams SET is_live = 1, ended_at = NULL, started_at = ? WHERE id = ?').run(new Date(Date.now() - 60_000).toISOString(), streamId);++ r = await press.fromPress({ channel: await store.channelByUsername('ann'), presser: bob, id: element.id });++ assert.equal(r.ok, true);++ }++++ // ── Role: a follower-only element refuses a non-follower ─────────────────++ {++ reset();++ const element = await make({ name: 'Followers only', position: 20, recipe: recipe({ conditions: { role: 'follower' } }) });++ let r = await press.fromPress({ channel: await store.channelByUsername('ann'), presser: cid, id: element.id });++ assert.equal(r.code, 'role');++ assert.equal(r.status, 403);++ assert.equal(sent.alerts.length, 0);++ r = await press.fromPress({ channel: await store.channelByUsername('ann'), presser: bob, id: element.id });++ assert.equal(r.ok, true, 'a follower gets through');++ }++++ // ── Cost: checked by Live's channel-points ledger, and refunded ──────────++ {++ reset();++ const have = Number(await db.getChannelPoints(3, 2)) || 0;++ await db.applyChannelPoints({ userId: 3, streamerId: 2, delta: 100 - have, key: 'reset:bob' });++ const element = await make({ name: 'Costs 50', position: 30, recipe: recipe({ conditions: { cost: { points: 50 } } }) });++ let r = await press.fromPress({ channel: await store.channelByUsername('ann'), presser: bob, id: element.id });++ assert.equal(r.ok, true);++ assert.equal(r.points_spent, 50);++ assert.equal(r.balance, 50);++ assert.equal(await db.getChannelPoints(3, 2), 50);++ // Not enough: the owner refuses before any action runs.++ await db.applyChannelPoints({ userId: 3, streamerId: 2, delta: -45, key: 'spend:bob' }); // 5 left++ reset();++ r = await press.fromPress({ channel: await store.channelByUsername('ann'), presser: bob, id: element.id });++ assert.equal(r.code, 'points');++ assert.equal(r.status, 402);++ assert.equal(sent.alerts.length, 0);++ // A product's refusal refunds the points.++ await db.applyChannelPoints({ userId: 3, streamerId: 2, delta: 95, key: 'topup2:bob' });++ delivery.message = async () => null;++ const chained = await make({++ name: 'Counter then chat', position: 31, capabilities: ['screen.counter', 'chat.message'],++ recipe: recipe({++ conditions: { cost: { points: 10 }, cooldown: { per_person_seconds: 0 } },++ actions: [++ { kind: 'screen.counter', product: 'live', key: 'dances', by: 1 },++ { kind: 'chat.message', product: 'chat', text: 'the deck says hi' },++ ],++ }),++ });++ reset();++ r = await press.fromPress({ channel: await store.channelByUsername('ann'), presser: bob, id: chained.id });++ assert.equal(r.code, 'action');++ assert.equal(r.refunded, true);++ assert.equal(r.points_spent, 0);++ assert.equal(await db.getChannelPoints(3, 2), 100, 'a refused action gives the points back');++ delivery.message = async (body) => { sent.chat.push(body); return 900 + sent.chat.length; };++ }++++ // ── Cooldown and daily cap ───────────────────────────────────────────────++ {++ reset();++ const element = await make({ name: 'Slow', position: 40, recipe: recipe({ conditions: { cooldown: { per_person_seconds: 60 } } }) });++ const channel = await store.channelByUsername('ann');++ let r = await press.fromPress({ channel, presser: bob, id: element.id });++ assert.equal(r.ok, true);++ r = await press.fromPress({ channel, presser: bob, id: element.id });++ assert.equal(r.code, 'cooldown');++ assert.equal(r.status, 429);++ assert.ok(r.retry_after_ms > 50000 && r.retry_after_ms <= 60000, `retry after ${r.retry_after_ms}`);++ const capped = await make({ name: 'Once a day', position: 41, recipe: recipe({ conditions: { daily_cap: { per_person: 1 } } }) });++ r = await press.fromPress({ channel, presser: bob, id: capped.id });++ assert.equal(r.ok, true);++ r = await press.fromPress({ channel, presser: bob, id: capped.id });++ assert.equal(r.code, 'daily_cap');++ assert.equal(r.status, 429);++ }++++ // ── The state reducer, the press moment, the timeline and the events ─────++ {++ reset();++ const element = await make({ name: 'Dance', position: 50 });++ const before = store.shape((await store.byPublicId(2, element.id)).row, { revision: 0, values: { dances: 0 } });++ assert.equal(before.state.revision, 0);++ const r = await press.fromPress({ channel: await store.channelByUsername('ann'), presser: bob, id: element.id });++ assert.equal(r.ok, true);++ assert.deepEqual(r.state, { revision: 1, values: { dances: 1 } });++ assert.equal(sent.alerts.length, 1);++ assert.equal(sent.alerts[0].kind, 'element');++ assert.equal(sent.alerts[0].who.name, 'Bob');++ assert.equal(sent.alerts[0].text, 'Dance');++ const timeline = await raw.prepare("SELECT kind, text, start_sec FROM stream_timeline_events WHERE stream_id = ? AND kind = 'element' ORDER BY id DESC LIMIT 1").get(streamId);++ assert.equal(timeline.kind, 'element');++ assert.match(timeline.text, /Bob pressed Dance/);++ assert.ok(timeline.start_sec >= 55);++ const stateFrame = sent.events.find((e) => e.frame && e.frame.type === 'element-state');++ assert.ok(stateFrame, 'the new state goes to the channel room');++ assert.equal(stateFrame.frame.revision, 1);++ assert.deepEqual(stateFrame.frame.values, { dances: 1 });++ assert.ok(sent.envelopes.some((e) => e.event_type === 'live.element.pressed' && e.payload.element_name === 'Dance' && e.payload.presser_name === 'Bob'));++ assert.ok(sent.envelopes.some((e) => e.event_type === 'live.element.state' && e.payload.revision === 1));++ }++++ // ── An undeclared capability is refused before anything runs ─────────────++ {++ reset();++ const row = await raw.prepare(`INSERT INTO elements (public_id, user_id, name, look, recipe, capabilities, initial_state, sharing, active, position)++ VALUES (?, 2, 'Sneaky', ?, ?, ?, '{}'::jsonb, 'private', 1, 60) RETURNING *`)++ .get('el_01JAB2C3D4E5F6G7H8J9K0MND4', JSON.stringify(look('Sneaky')), JSON.stringify(recipe()), JSON.stringify(['screen.alert']));++ await raw.prepare('INSERT INTO element_state (element_id, revision, values) VALUES (?, 0, ?)').run(row.id, '{}');++ const r = await press.fromPress({ channel: await store.channelByUsername('ann'), presser: bob, id: 'el_01JAB2C3D4E5F6G7H8J9K0MND4' });++ assert.equal(r.code, 'capability');++ assert.equal(r.status, 403);++ assert.equal(sent.alerts.length, 0);++ assert.equal(sent.bots.length, 0);++ }++++ // ── Operator-only elements: Bot's own operators count ───────────────────++ {++ reset();++ await raw.prepare('UPDATE channels SET bot_robot_id = ? WHERE id = ?').run('rob_01J8Z4M2Q0R7T9YV3K6N8P1W2X', channel.id);++ const op = await make({ name: 'Operators only', position: 60, recipe: recipe({ conditions: { role: 'operator' } }) });++ let r = await press.fromPress({ channel: await store.channelByUsername('ann'), presser: bob, id: op.id });++ assert.equal(r.ok, true, 'a Bot operator may press an operator element');++ r = await press.fromPress({ channel: await store.channelByUsername('ann'), presser: cid, id: op.id });++ assert.equal(r.code, 'role');++ await raw.prepare('UPDATE channels SET bot_robot_id = NULL WHERE id = ?').run(channel.id);++ }++++ // ── Robot actions go through Bot's command path, and the turn rule ───────++ {++ reset();++ await raw.prepare('UPDATE channels SET bot_robot_id = ? WHERE id = ?').run('rob_01J8Z4M2Q0R7T9YV3K6N8P1W2X', channel.id);++ const say = await make({++ name: 'Say hi', position: 70, capabilities: ['robot.say'],++ recipe: recipe({ actions: [{ kind: 'robot.say', product: 'bot', text: 'hello chat' }] }),++ });++ let r = await press.fromPress({ channel: await store.channelByUsername('ann'), presser: bob, id: say.id });++ assert.equal(r.ok, true, JSON.stringify(r));++ assert.equal(sent.bots.length, 1);++ assert.equal(sent.bots[0][0], 'rob_01J8Z4M2Q0R7T9YV3K6N8P1W2X');++ assert.equal(sent.bots[0][1], BOB);++ assert.equal(sent.bots[0][2].kind, 'say');++ // Bot's refusal is a refusal, not a success.++ sent.botRefuse = true;++ r = await press.fromPress({ channel: await store.channelByUsername('ann'), presser: bob, id: say.id });++ assert.equal(r.code, 'action');++ assert.match(r.message, /not an operator/);++ // robot_turn_only with no turn answer refuses before Bot is asked.++ reset();++ const turnOnly = await make({++ name: 'Only my turn', position: 71, capabilities: ['robot.say'],++ recipe: recipe({ conditions: { robot_turn_only: true }, actions: [{ kind: 'robot.say', product: 'bot', text: 'hello' }] }),++ });++ r = await press.fromPress({ channel: await store.channelByUsername('ann'), presser: bob, id: turnOnly.id });++ assert.equal(r.code, 'robot_turn');++ assert.equal(sent.bots.length, 0);++ await raw.prepare('UPDATE channels SET bot_robot_id = NULL WHERE id = ?').run(channel.id);++ }++++ // ── A chat command through Chat's own effect path ────────────────────────++ {++ reset();++ const dance = await make({++ name: 'Dance command', position: 80, recipe: recipe({ triggers: [{ kind: 'chat_command', command: '!dance' }] }),++ });++ const none = await press.fromChat({ channelUserId: 2, user: { id: 3 }, text: 'hello there' });++ assert.deepEqual(none, []);++ const results = await press.fromChat({ channelUserId: 2, user: { id: 3 }, text: '!dance please' });++ assert.equal(results.length, 1);++ assert.equal(results[0].ok, true);++ assert.equal(results[0].element_id, dance.id);++ assert.deepEqual(results[0].state.values, { dances: 1 });++ const anon = await press.fromChat({ channelUserId: 2, user: { id: null }, text: '!dance' });++ assert.deepEqual(anon, [], 'anonymous chat cannot spend or trigger in stage 1');++ void none;++ }++++ // ── The owner can switch the deck off again ──────────────────────────────++ {++ await store.setEnabled(2, false);++ const r = await press.fromPress({ channel: await store.channelByUsername('ann'), presser: bob, id: (await store.list(2))[0].id });++ assert.equal(r.code, 'disabled');++ }++++ // ── The HTTP API: the owner's library, the public deck, the press ────────++ {++ const auth = require('../server/auth/auth');++ auth.requireAuth = (req, res, next) => {++ const id = Number(req.headers['x-test-user'] || 0);++ db.getUserById(id).then((u) => (u ? (req.user = u, next()) : res.status(401).json({ error: 'Authentication required' }))).catch(next);++ };++ const express = require('express');++ const http = require('http');++ const routes = require('../server/elements/routes');++ const app = express();++ app.use(express.json());++ app.use('/api/me/elements', routes.ownerRouter);++ app.use('/api/channels', routes.channelRouter);++ const server = http.createServer(app);++ await new Promise((r) => server.listen(0, '127.0.0.1', r));++ const call = async (method, path, body, user) => {++ const res = await fetch(`http://127.0.0.1:${server.address().port}${path}`, {++ method, headers: { 'content-type': 'application/json', ...(user ? { 'x-test-user': String(user) } : {}) },++ body: body === undefined ? undefined : JSON.stringify(body),++ });++ return { status: res.status, json: await res.json().catch(() => null), headers: res.headers };++ };++ await store.setEnabled(2, true);++ const deckRead = await call('GET', '/api/channels/ann/elements');++ assert.equal(deckRead.status, 200);++ assert.equal(deckRead.json.surface, 'live.deck');++ assert.equal(deckRead.json.enabled, true);++ assert.equal(deckRead.json.live, true);++ assert.equal(deckRead.json.viewer.signed_in, false);++ assert.ok(deckRead.json.elements.length > 0);++ assert.equal(deckRead.json.elements[0].can_press, false);++ assert.equal(deckRead.json.elements[0].reason, 'sign_in');++++ const library = await call('GET', '/api/me/elements', undefined, 2);++ assert.equal(library.status, 200);++ assert.equal(library.json.enabled, true);++ assert.ok(Array.isArray(library.json.elements) && library.json.elements.length > 0);++++ const made = await call('POST', '/api/me/elements', {++ name: 'API button', look: look('API button'), recipe: recipe(), capabilities: ['screen.counter'], initial_state: { dances: 0 },++ }, 2);++ assert.equal(made.status, 201, JSON.stringify(made.json));++ assert.match(made.json.element.id, /^el_/);++ const patched = await call('PATCH', `/api/me/elements/${made.json.element.id}`, { name: 'API button 2' }, 2);++ assert.equal(patched.status, 200);++ assert.equal(patched.json.element.name, 'API button 2');++ const bad = await call('POST', '/api/me/elements', {++ name: 'Undeclared', look: look(), recipe: recipe(), capabilities: ['screen.alert'],++ }, 2);++ assert.equal(bad.status, 400);++ assert.ok(Array.isArray(bad.json.errors) && bad.json.errors.length > 0);++++ const pressed = await call('POST', `/api/channels/ann/elements/${made.json.element.id}/press`, {}, 3);++ assert.equal(pressed.status, 200, JSON.stringify(pressed.json));++ assert.equal(pressed.json.ok, true);++ assert.deepEqual(pressed.json.state, { revision: 1, values: { dances: 1 } });++ assert.equal(typeof pressed.json.balance, 'number');++ const deleted = await call('DELETE', `/api/me/elements/${made.json.element.id}`, undefined, 2);++ assert.equal(deleted.status, 200);++ assert.equal((await call('POST', `/api/channels/ann/elements/${made.json.element.id}/press`, {}, 3)).status, 404);++++ const settingsOff = await call('PUT', '/api/me/elements/settings', { enabled: false }, 2);++ assert.equal(settingsOff.status, 200);++ assert.deepEqual(settingsOff.json, { enabled: false });++ const hidden = await call('GET', '/api/channels/ann/elements');++ assert.equal(hidden.json.enabled, false);++ assert.deepEqual(hidden.json.elements, []);++ await store.setEnabled(2, true);++ server.close();++ }++++ quiet('elements: all checks passed');++ process.exit(0);++})().catch((err) => { quiet(err); process.exit(1); });
@@ -182,7 +182,7 @@ Minimum `.env`: `BASE_URL`, `JWT_SECRET`, `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL
@@ -182,7 +182,7 @@ Minimum `.env`: `BASE_URL`, `JWT_SECRET`, `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL
- [docs/restream-branding.md](docs/restream-branding.md) — branding guide for restream channels.
- [docs/restream-branding.md](docs/restream-branding.md) — branding guide for restream channels.
<!-- versions:start -->
<!-- versions:start -->
-- openvibe-contracts: v0.408.0
-- openvibe-sdk: v0.150.0
-- openvibe-shared: v4.211.0
+- openvibe-contracts: v0.410.0
+- openvibe-sdk: v0.152.0
+- openvibe-shared: v4.212.0
<!-- versions:end -->
<!-- versions:end -->
@@ -5,9 +5,9 @@
@@ -5,9 +5,9 @@
"runtime": "node 22, express, PostgreSQL, node-media-server, mediasoup; no build step",
"runtime": "node 22, express, PostgreSQL, node-media-server, mediasoup; no build step",
"port": 3000,
"port": 3000,
"domain": "openvibe.live (www, ingest, whip)",
"domain": "openvibe.live (www, ingest, whip)",
- "contracts": "openvibe-contracts v0.408.0",
- "sdk": "openvibe-sdk v0.150.0",
- "shared": "openvibe-shared v4.211.0",
+ "contracts": "openvibe-contracts v0.410.0",
+ "sdk": "openvibe-sdk v0.152.0",
+ "shared": "openvibe-shared v4.212.0",
"capabilities": [
"capabilities": [
"live.chat_context.read",
"live.chat_context.read",
"live.chat_effects.write",
"live.chat_effects.write",
new file mode 100644
new file mode 100644
@@ -0,0 +1,58 @@
@@ -0,0 +1,58 @@
+-- phase: expand
+-- Elements, stage 1 (owner ask 2026-11-11): a streamer's library of buttons and controls people
+-- make, the store each one keeps, and the presses that authorise a run. The deck is a new surface
+-- on the channel page, so it is OFF until the streamer turns it on (channels.deck_enabled); the
+-- migration only adds, and the previous release reads every new table as absent.
+CREATE TABLE IF NOT EXISTS elements (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ public_id text COLLATE "C" NOT NULL UNIQUE,
+ user_id bigint NOT NULL,
+ name text COLLATE "C" NOT NULL,
+ description text COLLATE "C",
+ look jsonb NOT NULL,
+ recipe jsonb NOT NULL,
+ capabilities jsonb NOT NULL,
+ initial_state jsonb NOT NULL DEFAULT '{}'::jsonb,
+ mod jsonb,
+ sharing text COLLATE "C" NOT NULL DEFAULT 'private',
+ active bigint NOT NULL DEFAULT 1,
+ position bigint NOT NULL DEFAULT 0,
+ revision bigint NOT NULL DEFAULT 1,
+ created_at text COLLATE "C" DEFAULT ov_now(),
+ updated_at text COLLATE "C" DEFAULT ov_now(),
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
+ CONSTRAINT elements_public_id_shape CHECK (public_id ~ '^el_[0-9A-HJKMNP-TV-Z]{26}$'),
+ CONSTRAINT elements_sharing CHECK (sharing IN ('private', 'link', 'gallery')),
+ CONSTRAINT elements_active CHECK (active IN (0, 1))
+);
+
+CREATE INDEX IF NOT EXISTS idx_elements_deck ON elements(user_id, active, position, id);
+CREATE INDEX IF NOT EXISTS idx_elements_public ON elements(public_id);
+
+CREATE TABLE IF NOT EXISTS element_state (
+ element_id bigint PRIMARY KEY,
+ revision bigint NOT NULL DEFAULT 0,
+ values jsonb NOT NULL DEFAULT '{}'::jsonb,
+ updated_at text COLLATE "C" DEFAULT ov_now(),
+ FOREIGN KEY (element_id) REFERENCES elements(id) ON DELETE CASCADE
+);
+
+CREATE TABLE IF NOT EXISTS element_press_log (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ element_id bigint NOT NULL,
+ presser_id bigint,
+ source text COLLATE "C" NOT NULL DEFAULT 'press',
+ points bigint NOT NULL DEFAULT 0,
+ created_at text COLLATE "C" DEFAULT ov_now(),
+ FOREIGN KEY (element_id) REFERENCES elements(id) ON DELETE CASCADE,
+ CONSTRAINT element_press_source CHECK (source IN ('press', 'chat')),
+ CONSTRAINT element_press_points CHECK (points >= 0)
+);
+
+CREATE INDEX IF NOT EXISTS idx_element_press_person ON element_press_log(element_id, presser_id, created_at);
+CREATE INDEX IF NOT EXISTS idx_element_press_all ON element_press_log(element_id, created_at);
+
+-- The owner switch: a channel's Viewer Deck is off until the streamer opens Elements for it.
+ALTER TABLE channels ADD COLUMN deck_enabled bigint NOT NULL DEFAULT 0;
+ALTER TABLE channels DROP CONSTRAINT IF EXISTS channels_deck_enabled;
+ALTER TABLE channels ADD CONSTRAINT channels_deck_enabled CHECK (deck_enabled IN (0, 1));
@@ -24,8 +24,8 @@
@@ -24,8 +24,8 @@
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
"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.408.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.150.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.410.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.152.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.212.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.212.0",
"pg": "^8.23.1",
"pg": "^8.23.1",
"sanitize-html": "^2.17.7",
"sanitize-html": "^2.17.7",
@@ -3373,9 +3373,9 @@
@@ -3373,9 +3373,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.408.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.408.0",
- "integrity": "sha512-Q2XjfuCDyMFd88CIKVwdZKR9IwZsxZB41cFKxaIWVIrmCt8YO5P2zj8beqvBanqpNgOjPB74HTU/0JCMTbyA+Q==",
+ "version": "0.410.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.410.0",
+ "integrity": "sha512-rmmq2mebXim2fv+sip2YQ0PrrZLxGUhEfe1w+1EFfGdoVvLI5tp0/eavY2sW6/VU+rzs7cQMC/A0NZD5th5wrA==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -3389,9 +3389,9 @@
@@ -3389,9 +3389,9 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"node_modules/openvibe-sdk": {
- "version": "0.150.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.150.0",
- "integrity": "sha512-5k2PovDQ2zhYXxLHtNNm1Q3rzPMXLpdh0lIYdl9kfa6D2/X2gYozT5W0CHRCIMAK4UhjbsZaXOSMM8GbkJWsNw==",
+ "version": "0.152.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.152.0",
+ "integrity": "sha512-/nZbjF9KTJPa3582v60tx238BBDa47tJAmxVkbgSWRReXgMHHGAhV8FGKqFmxPHOxLzvMgVp0Ei4mz4A4jsUvQ==",
"license": "MIT",
"license": "MIT",
"bin": {
"bin": {
"openvibe-download": "bin/openvibe-download.js"
"openvibe-download": "bin/openvibe-download.js"
@@ -44,8 +44,8 @@
@@ -44,8 +44,8 @@
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
"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.408.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.150.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.410.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.152.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.212.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.212.0",
"pg": "^8.23.1",
"pg": "^8.23.1",
"sanitize-html": "^2.17.7",
"sanitize-html": "^2.17.7",
@@ -74,6 +74,50 @@
@@ -74,6 +74,50 @@
display: flex; flex-direction: column;
display: flex; flex-direction: column;
}
}
+/* ── The Viewer Deck (Elements, stage 1) ───────────────────────────────────────
+ The streamer's active elements, on the channel page. On a computer it is the column between the player and
+ the chat; below 1180 px the page is one column, so it sits under the player. Plain kit buttons; the design of
+ the deck belongs to the main session. */
+.ch-deck {
+ flex: 0 0 300px;
+ min-width: 0;
+ min-height: 0;
+ overflow-y: auto;
+ padding: 14px 12px 18px;
+ border-left: 1px solid var(--border, rgba(255, 255, 255, 0.08));
+ background: var(--bg-card, #111827);
+}
+.ch-deck[hidden] { display: none !important; }
+.ch-deck-title {
+ margin: 0 0 10px;
+ font-size: 0.82rem;
+ font-weight: 800;
+ letter-spacing: 0.02em;
+ color: var(--text-secondary, #a8b3c4);
+}
+.ch-deck-list { display: grid; gap: 8px; }
+.ch-deck-item { display: grid; gap: 6px; min-width: 0; }
+.ch-deck-btn { width: 100%; min-height: 44px; justify-content: space-between; text-align: left; gap: 8px; }
+.ch-deck-btn[disabled] { opacity: 0.55; cursor: not-allowed; }
+.ch-deck-meta, .ch-deck-note, .ch-deck-state {
+ font-size: 0.75rem;
+ line-height: 1.35;
+ color: var(--text-secondary, #a8b3c4);
+ overflow-wrap: anywhere;
+}
+.ch-deck-note { color: var(--warning-text, var(--warning, #f39c12)); }
+.ch-deck-state { font-variant-numeric: tabular-nums; }
+@media (max-width: 1180px) {
+ .ch-deck {
+ flex: none;
+ width: 100%;
+ border-left: 0;
+ border-top: 1px solid var(--border, rgba(255, 255, 255, 0.08));
+ padding: 10px 12px;
+ }
+ .ch-deck-list { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); }
+}
+
.stream-nsfw-gate-card {
.stream-nsfw-gate-card {
width: min(100%, 620px);
width: min(100%, 620px);
display: flex;
display: flex;
@@ -155,6 +155,7 @@
@@ -155,6 +155,7 @@
"/js/channel-support.js",
"/js/channel-support.js",
"/js/card-preview.js",
"/js/card-preview.js",
"/js/app-channel.js",
"/js/app-channel.js",
+ "/js/viewer-deck.js",
"/js/bench-stream-watch.js",
"/js/bench-stream-watch.js",
"/js/agent-cards.js"
"/js/agent-cards.js"
],
],
@@ -186,6 +186,10 @@
@@ -186,6 +186,10 @@
<!-- Weather spans the full width under the info bar (left-aligned) -->
<!-- Weather spans the full width under the info bar (left-aligned) -->
<div class="ch-weather-widget ch-weather-full" id="ch-weather-widget" style="display:none"></div>
<div class="ch-weather-widget ch-weather-full" id="ch-weather-widget" style="display:none"></div>
</div>
</div>
+ <!-- The Viewer Deck (Elements): the streamer's active elements. Filled by /js/viewer-deck.js;
+ beside the player on a computer and under it on a phone (channel.css). Hidden until the
+ streamer has switched Elements on and has at least one active element. -->
+ <aside class="ch-deck" id="ch-deck" data-surface="live.deck" aria-label="Deck" hidden></aside>
<!-- Chat Resize Handle -->
<!-- Chat Resize Handle -->
<!-- Chat Sidebar -->
<!-- Chat Sidebar -->
<div class="chat-sidebar" id="chat-sidebar">
<div class="chat-sidebar" id="chat-sidebar">
@@ -315,6 +319,8 @@
@@ -315,6 +319,8 @@
<!-- Customizable offline screen (image / video / custom HTML) — filled by JS -->
<!-- Customizable offline screen (image / video / custom HTML) — filled by JS -->
<div class="ch-offline-screen" id="ch-offline-screen"></div>
<div class="ch-offline-screen" id="ch-offline-screen"></div>
</div><!-- /.stream-main -->
</div><!-- /.stream-main -->
+ <!-- The Viewer Deck while the channel is offline: same elements, disabled with the offline note. -->
+ <aside class="ch-deck" id="ch-deck-offline" aria-label="Deck" hidden></aside>
<!-- Streamer's persistent chat (works while offline) — sidebar, mirrors live layout -->
<!-- Streamer's persistent chat (works while offline) — sidebar, mirrors live layout -->
<div class="offline-global-chat chat-sidebar" id="offline-global-chat">
<div class="offline-global-chat chat-sidebar" id="offline-global-chat">
<div class="chat-header">
<div class="chat-header">
new file mode 100644
new file mode 100644
@@ -0,0 +1,266 @@
@@ -0,0 +1,266 @@
+'use strict';
+/**
+ * The Viewer Deck (Elements, stage 1): the streamer's active elements on their channel page.
+ *
+ * - GET /api/channels/:name/elements the elements, their state and what this viewer may press
+ * - POST /api/channels/:name/elements/:id/press
+ * - /ws/chat the channel's room for `element-state` frames (the overlay path)
+ *
+ * The server is the authority: every press re-checks the role, live state, cooldowns, daily caps and
+ * channel points, and each product checks its own action again. This file only draws the states the
+ * API answers (empty, ready, on cooldown, costs points, not allowed for you, offline, signed out) and
+ * keeps the element's own state live. No HTML from the server or the element is inserted as markup.
+ */
+(function () {
+ var containers = [];
+ var state = {
+ username: null,
+ data: null,
+ inflight: Object.create(null),
+ cooldowns: Object.create(null),
+ ws: null,
+ wsTimer: null,
+ syncTimer: null,
+ staleTimer: null,
+ lastLoad: 0,
+ };
+
+ function byId(id) { return document.getElementById(id); }
+ function node(tag, cls, text) {
+ var n = document.createElement(tag);
+ if (cls) n.className = cls;
+ if (text !== undefined && text !== null) n.textContent = String(text);
+ return n;
+ }
+ function active() {
+ var page = byId('page-channel');
+ return !!(page && page.classList.contains('active'));
+ }
+ function username() {
+ return typeof currentChannelUsername === 'string' && currentChannelUsername ? currentChannelUsername : null;
+ }
+ function call(path, opts) {
+ if (typeof api !== 'function') return Promise.reject(new Error('The page is not ready.'));
+ return api(path, opts);
+ }
+ function points(n) {
+ var v = Number(n) || 0;
+ return v.toLocaleString() + (v === 1 ? ' point' : ' points');
+ }
+ function mmss(ms) {
+ var s = Math.max(0, Math.ceil(ms / 1000));
+ return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0');
+ }
+ function stateText(values) {
+ var parts = [];
+ Object.keys(values || {}).sort().slice(0, 6).forEach(function (k) {
+ var v = values[k];
+ if (v === null || v === undefined) return;
+ if (Array.isArray(v)) v = v.join(', ');
+ parts.push(k.replace(/_/g, ' ') + ': ' + String(v));
+ });
+ return parts.join(' · ');
+ }
+
+ function push(container, title, deck) {
+ container.replaceChildren();
+ container.hidden = false;
+ var h = node('h2', 'ch-deck-title', title);
+ container.append(h);
+ if (!deck.elements.length) return;
+ var list = node('div', 'ch-deck-list');
+ deck.elements.forEach(function (el) {
+ var item = node('div', 'ch-deck-item');
+ item.dataset.eid = el.id;
+ var btn = node('button', 'ovk-btn ovk-btn--primary ch-deck-btn');
+ btn.type = 'button';
+ btn.dataset.eid = el.id;
+ btn.append(node('span', 'ch-deck-label', el.look && el.look.label ? el.look.label : el.name));
+ var meta = node('span', 'ch-deck-meta');
+ btn.append(meta);
+ var left = state.cooldowns[el.id] || 0;
+ var cost = Number(el.cost_points) || 0;
+ var bits = [];
+ if (cost) bits.push(points(cost));
+ if (deck.viewer && deck.viewer.signed_in && deck.viewer.points !== null && cost) bits.push('you have ' + points(deck.viewer.points));
+ if (left > 0) bits.push('on cooldown ' + mmss(left));
+ meta.textContent = bits.join(' · ');
+ if (!deck.live) {
+ btn.disabled = true;
+ meta.textContent = 'While ' + (deck.channel.display_name || deck.channel.username) + ' is live';
+ } else if (!el.can_press) {
+ btn.disabled = true;
+ if (!bits.length) meta.textContent = el.message || 'Not available';
+ } else if (state.inflight[el.id]) {
+ btn.disabled = true;
+ meta.textContent = 'Pressing…';
+ }
+ btn.addEventListener('click', function () { press(el.id); });
+ item.append(btn);
+ if (el.message && !el.can_press && deck.live) item.append(node('p', 'ch-deck-note', el.message));
+ var values = el.state && el.state.values ? el.state.values : {};
+ var text = stateText(values);
+ if (text) item.append(node('p', 'ch-deck-state', text));
+ list.append(item);
+ });
+ container.append(list);
+ if (!deck.enabled) {
+ container.append(node('p', 'ch-deck-note', 'Elements are off for this channel.'));
+ }
+ }
+
+ function render() {
+ containers = [byId('ch-deck'), byId('ch-deck-offline')].filter(Boolean);
+ if (!containers.length) return;
+ var deck = state.data;
+ if (!deck || !deck.enabled || !deck.elements.length) {
+ containers.forEach(function (c) { c.hidden = true; c.replaceChildren(); });
+ return;
+ }
+ var title = (deck.channel.display_name || deck.channel.username) + "'s deck";
+ containers.forEach(function (c) { push(c, title, deck); });
+ }
+
+ function refreshLater(ms) {
+ clearTimeout(state.staleTimer);
+ state.staleTimer = setTimeout(function () { load(true); }, ms);
+ }
+
+ async function press(id) {
+ var deck = state.data;
+ if (!deck || !deck.live || state.inflight[id]) return;
+ var el = deck.elements.filter(function (x) { return x.id === id; })[0];
+ if (!el || !el.can_press) return;
+ state.inflight[id] = true;
+ render();
+ try {
+ var out = await call('/channels/' + encodeURIComponent(state.username) + '/elements/' + encodeURIComponent(id) + '/press', { method: 'POST', body: {} });
+ if (out && out.state) { el.state = out.state; }
+ if (out && out.balance !== undefined && deck.viewer) deck.viewer.points = out.balance;
+ if (out && out.cooldown_ms) state.cooldowns[id] = Date.now() + Number(out.cooldown_ms);
+ state.inflight[id] = false;
+ render();
+ refreshLater(1200);
+ } catch (err) {
+ var data = (err && err.data) || {};
+ el.message = data.error || (err && err.message) || 'That did not work.';
+ el.can_press = false;
+ if (data.retry_after_ms) { state.cooldowns[id] = Date.now() + Number(data.retry_after_ms); el.can_press = false; }
+ state.inflight[id] = false;
+ render();
+ refreshLater(data.retry_after_ms ? Math.min(Number(data.retry_after_ms), 15000) : 1500);
+ }
+ }
+
+ function updateState(frame) {
+ if (!state.data || !frame || !frame.element_id) return;
+ if (frame.channel && frame.channel.username && frame.channel.username.toLowerCase() !== String(state.username).toLowerCase()) return;
+ var el = state.data.elements.filter(function (x) { return x.id === frame.element_id; })[0];
+ if (!el) return;
+ var current = el.state && Number(el.state.revision) || 0;
+ if (Number(frame.revision) < current) return;
+ el.state = { revision: Number(frame.revision) || 0, values: frame.values || {} };
+ containers = [byId('ch-deck'), byId('ch-deck-offline')].filter(Boolean);
+ containers.forEach(function (c) {
+ var item = c.querySelector('[data-eid="' + frame.element_id + '"]');
+ if (!item) return;
+ var line = item.querySelector('.ch-deck-state');
+ var text = stateText(el.state.values);
+ if (!line) { line = node('p', 'ch-deck-state'); item.append(line); }
+ line.textContent = text;
+ });
+ }
+
+ function closeSocket() {
+ if (state.ws) { try { state.ws.close(); } catch (e) { /* already closed */ } state.ws = null; }
+ clearTimeout(state.wsTimer);
+ }
+
+ function connect(deck) {
+ closeSocket();
+ if (!deck || !deck.live || !deck.stream_id || !deck.elements.length) return;
+ var proto = location.protocol === 'https:' ? 'wss' : 'ws';
+ var ws;
+ try { ws = new WebSocket(proto + '://' + location.host + '/ws/chat?stream=' + encodeURIComponent(deck.stream_id)); } catch (e) { return; }
+ state.ws = ws;
+ ws.onopen = function () {
+ try { ws.send(JSON.stringify({ type: 'join', streamId: deck.stream_id, channel: state.username })); } catch (e) { /* the join retries with the socket */ }
+ };
+ ws.onmessage = function (e) {
+ try { var m = JSON.parse(e.data); if (m && m.type === 'element-state') updateState(m); } catch (err) { /* not ours */ }
+ };
+ ws.onclose = function () {
+ if (state.ws !== ws) return;
+ state.ws = null;
+ if (active() && state.username === username()) {
+ state.wsTimer = setTimeout(function () { if (state.data) connect(state.data); }, 3000);
+ }
+ };
+ }
+
+ async function load(fresh) {
+ var name = username();
+ if (!name || !active()) return;
+ if (!fresh && state.username === name && state.data && Date.now() - state.lastLoad < 4000) return;
+ state.lastLoad = Date.now();
+ try {
+ var deck = await call('/channels/' + encodeURIComponent(name) + '/elements');
+ if (username() !== name) return; // the page moved to another channel while this was in flight
+ state.username = name;
+ // Keep a local cooldown the server has not seen yet (a press answered a moment ago).
+ (deck.elements || []).forEach(function (el) {
+ if (state.cooldowns[el.id] && state.cooldowns[el.id] > Date.now()) el.can_press = false;
+ });
+ state.data = deck;
+ render();
+ connect(deck);
+ } catch (err) {
+ if (err && err.status === 404) { state.data = null; render(); }
+ }
+ }
+
+ function sync() {
+ if (!active()) return;
+ var name = username();
+ if (!name) return;
+ if (name !== state.username || !state.data) { load(false); return; }
+ if (Date.now() - state.lastLoad > 30000) load(true);
+ }
+
+ function tick() {
+ var now = Date.now();
+ var expired = false;
+ Object.keys(state.cooldowns).forEach(function (id) {
+ if (state.cooldowns[id] && state.cooldowns[id] <= now) { delete state.cooldowns[id]; expired = true; }
+ });
+ if (!state.data) return;
+ state.data.elements.forEach(function (el) {
+ var left = state.cooldowns[el.id] || 0;
+ containers = containers.length ? containers : [byId('ch-deck'), byId('ch-deck-offline')].filter(Boolean);
+ containers.forEach(function (c) {
+ var btn = c.querySelector('[data-eid="' + el.id + '"]');
+ if (!btn) return;
+ var meta = btn.querySelector('.ch-deck-meta');
+ if (meta && left > 0) meta.textContent = 'On cooldown ' + mmss(left);
+ if (btn.disabled && !left && state.data.live) {
+ var wasCooling = meta && /cooldown/i.test(meta.textContent || '');
+ if (wasCooling) { btn.disabled = false; meta.textContent = ''; }
+ }
+ });
+ });
+ if (expired) refreshLater(300);
+ }
+
+ function boot() {
+ state.syncTimer = setInterval(sync, 1500);
+ setInterval(tick, 1000);
+ window.addEventListener('hashchange', sync);
+ window.addEventListener('popstate', sync);
+ window.addEventListener('openvibe-auth-changed', function () { state.data = null; state.username = null; load(true); });
+ document.addEventListener('visibilitychange', function () { if (!document.hidden) sync(); });
+ sync();
+ }
+
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
+ else boot();
+})();
@@ -74,6 +74,7 @@ html[data-ov-motion=off] .al::after{display: none}
@@ -74,6 +74,7 @@ html[data-ov-motion=off] .al::after{display: none}
sub: { icon: 'fa-star', say: 'subscribed' },
sub: { icon: 'fa-star', say: 'subscribed' },
raid: { icon: 'fa-users', say: 'raided' },
raid: { icon: 'fa-users', say: 'raided' },
goal: { icon: 'fa-trophy', say: 'Goal reached' },
goal: { icon: 'fa-trophy', say: 'Goal reached' },
+ element: { icon: 'fa-wand-magic-sparkles', say: 'pressed' },
};
};
var queue = [], showing = false, seen = new Set();
var queue = [], showing = false, seen = new Set();
function el(tag, cls, kids) { var n = document.createElement(tag); if (cls) n.className = cls; (kids || []).forEach(function (k) { if (k != null) n.append(k); }); return n; }
function el(tag, cls, kids) { var n = document.createElement(tag); if (cls) n.className = cls; (kids || []).forEach(function (k) { if (k != null) n.append(k); }); return n; }
@@ -97,6 +98,7 @@ html[data-ov-motion=off] .al::after{display: none}
@@ -97,6 +98,7 @@ html[data-ov-motion=off] .al::after{display: none}
else { what.append(k.say); if (amt) { what.append(' '); var e2 = el('em'); e2.textContent = amt; what.append(e2); } }
else { what.append(k.say); if (amt) { what.append(' '); var e2 = el('em'); e2.textContent = amt; what.append(e2); } }
var msg = el('p', 'al-msg');
var msg = el('p', 'al-msg');
if (a.kind === 'tip' && a.text) msg.textContent = String(a.text).slice(0, 200);
if (a.kind === 'tip' && a.text) msg.textContent = String(a.text).slice(0, 200);
+ if (a.kind === 'element' && a.text) msg.textContent = String(a.text).slice(0, 80);
var t = el('div', 'al-t', [el('p', 'al-who', [name]), what, msg]);
var t = el('div', 'al-t', [el('p', 'al-who', [name]), what, msg]);
return el('div', 'al', [ic, t]);
return el('div', 'al', [ic, t]);
}
}
@@ -73,4 +73,34 @@ async function post(robotId, route, subject, body) {
@@ -73,4 +73,34 @@ async function post(robotId, route, subject, body) {
const command = (robotId, subject, { id, kind, value, ms }) => post(robotId, 'commands', subject, { id, kind, value, ms });
const command = (robotId, subject, { id, kind, value, ms }) => post(robotId, 'commands', subject, { id, kind, value, ms });
const controlTicket = (robotId, subject) => post(robotId, 'control-ticket', subject, {});
const controlTicket = (robotId, subject) => post(robotId, 'control-ticket', subject, {});
-module.exports = { command, controlTicket, BotRefusal, BotUnavailable, AUDIENCE };
+// The role a person holds on a robot, for the element deck's operator-only buttons. Bot answers the
+// service that may control the robot (the same capability as a command); a miss or an unreachable Bot is
+// "no role", because the command itself still asks Bot and Bot decides. Cached 30 seconds per person.
+const ROLE_TTL_MS = 30 * 1000;
+const _roles = new Map();
+
+async function role(robotId, subject) {
+ if (!robotId || !subject) return null;
+ const key = `${robotId}|${subject}`;
+ const hit = _roles.get(key);
+ if (hit && Date.now() - hit.at < ROLE_TTL_MS) return hit.role;
+ if (drill()) return null;
+ const tokens = serviceTokens();
+ if (!tokens) return null;
+ let res;
+ try {
+ res = await fetch(`${baseUrl()}/api/v1/robots/${encodeURIComponent(robotId)}/role?subject=${encodeURIComponent(subject)}`, {
+ headers: { Accept: 'application/json', Authorization: `Bearer ${await tokens.getToken()}` },
+ signal: AbortSignal.timeout(TIMEOUT_MS),
+ });
+ } catch (err) { note(err); return null; }
+ if (res.status === 401) { tokens.invalidate && tokens.invalidate(); return null; }
+ if (!res.ok) return null;
+ const out = await res.json().catch(() => ({}));
+ const value = out && typeof out.role === 'string' ? out.role : null;
+ _roles.set(key, { role: value, at: Date.now() });
+ if (_roles.size > 5000) _roles.clear();
+ return value;
+}
+
+module.exports = { command, controlTicket, role, BotRefusal, BotUnavailable, AUDIENCE, _resetRoles: () => _roles.clear() };
@@ -428,6 +428,17 @@ effectsRouter.post('/chat-message', async (req, res) => {
@@ -428,6 +428,17 @@ effectsRouter.post('/chat-message', async (req, res) => {
}
}
} catch { /* non-critical */ }
} catch { /* non-critical */ }
}
}
+ // A chat command can be an Element trigger (Elements, stage 1): the same run authorises it, with the
+ // chatter as the presser. Commands are rare; a non-command returns after one string check.
+ if (b.channel_user_id && b.message) {
+ try {
+ await require('../elements/press').fromChat({
+ channelUserId: int(b.channel_user_id),
+ user: { id: b.user_id ? int(b.user_id) : null, isMod: !!b.is_mod, isStreamer: !!b.is_streamer },
+ text: String(b.message).slice(0, 400),
+ });
+ } catch (e) { console.warn('[Elements] chat command:', e && e.message); }
+ }
res.json({ coin });
res.json({ coin });
});
});
new file mode 100644
new file mode 100644
@@ -0,0 +1,400 @@
@@ -0,0 +1,400 @@
+'use strict';
+/**
+ * Elements, stage 1: one press, authorised by Live and run through openvibe-sdk/elements.
+ *
+ * A press is never an element's own power. Live checks the viewer's role (owner, channel moderator,
+ * VIP or subscriber, follower, everyone), whether the channel is live, the element's cooldowns and
+ * daily caps, and its channel-points cost (Live's own ledger, debited atomically); then each action
+ * is sent to the product that owns it: Live's screen and stream actions here, Bot's command path
+ * for a robot (turns, operators, limits, Stop, the e-stop and owner takeover are Bot's), Chat's
+ * ingress for a message. A refusal from any of them stops the run and refunds the points.
+ *
+ * The press is shown on stream through the existing overlay alert feed with the presser's name,
+ * written to the stream's AI timeline as a moment, and announced as live.element.pressed@1. A
+ * screen.counter/screen.state action moves the element's own store and Live broadcasts the new
+ * state as live.element.state@1 (and as an `element-state` frame in the channel room).
+ *
+ * Chat commands come in through Chat's existing /internal/chat-effects/chat-message effect; the
+ * same run authorises them, with the chatter as the presser.
+ */
+const db = require('../db/database');
+const identity = require('../auth/identity-sync');
+const permissions = require('../auth/permissions');
+const billing = require('../monetization/billing-actions');
+const delivery = require('../chat/chat-delivery');
+const overlay = require('../overlay/overlay-alerts');
+const bot = require('../bot/bot-client');
+const streamEvents = require('../events/stream-events');
+const store = require('./store');
+
+const STATUS = {
+ disabled: 404, offline: 409, not_found: 404, not_active: 404,
+ capability: 403, role: 403, points: 402, cooldown: 429, daily_cap: 429,
+ not_live: 409, robot_turn: 409, chance: 409, action: 409, limits: 500,
+ invalid: 400, trigger: 400, signed_out: 401, disabled_action: 409,
+};
+
+const ROBOT_PRESETS = { up: 1, center: 0, down: -1 };
+
+const refuse = (code, message, extra = {}) => ({ ok: false, code, message, status: STATUS[code] || 400, ...extra });
+const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
+
+const RANK = { everyone: 0, follower: 1, subscriber: 2, vip: 3, operator: 4, owner: 5 };
+
+/**
+ * The highest role this person holds in this channel, for a condition that needs `need`. The answer is
+ * enough to decide that condition (a rank), not a full audit: an everyone-only deck does not ask Chat
+ * or Billing anything, and a follower-only one asks them only for a person who does not follow.
+ */
+async function roleOf(channel, user, hints = {}, need = 'owner') {
+ if (!user || !user.id) return { role: null, signedIn: false };
+ const needRank = RANK[need] || 0;
+ if (Number(user.id) === Number(channel.user_id)) return { role: 'owner', signedIn: true };
+ if (hints.isStreamer) return { role: 'owner', signedIn: true };
+ if (needRank === 0) return { role: 'everyone', signedIn: true };
+ if (hints.isMod) return { role: 'operator', signedIn: true };
+ try { if (await permissions.isChannelModerator(user, channel.user_id)) return { role: 'operator', signedIn: true }; } catch { /* Chat may be unreachable; fall through */ }
+ // A channel bound to a robot: Bot's own operators are the People Operators the design names. Bot
+ // answers this with the control capability, and the command itself is still Bot's decision.
+ if (channel.bot_robot_id) {
+ try {
+ const subject = await identity.subjectOf(user.id);
+ const held = subject ? await bot.role(channel.bot_robot_id, subject) : null;
+ if (held === 'owner' || held === 'operator') return { role: 'operator', signedIn: true };
+ } catch { /* Bot is unreachable; a channel moderator still counts */ }
+ }
+ // A follower is the cheapest answer, so a follower-only element asks the follows table first. A
+ // non-follower then asks Billing/VIP, because a subscriber is above a follower even without Follow.
+ if (needRank <= RANK.follower) {
+ try {
+ const follow = await db.get('SELECT 1 AS yes FROM follows WHERE follower_id = ? AND streamer_id = ?', [user.id, channel.user_id]);
+ if (follow) return { role: 'follower', signedIn: true };
+ } catch { /* no follows table answer */ }
+ }
+ if (needRank <= RANK.vip) {
+ // Subscriber and VIP: one Billing/VIP answer covers both.
+ try {
+ const subject = await identity.subjectOf(user.id);
+ const creator = await identity.subjectOf(channel.user_id);
+ if (subject && creator) {
+ const e = await billing.entitlementOf(subject, creator);
+ if (e && e.active) return { role: e.via === 'vip' ? 'vip' : 'subscriber', signedIn: true };
+ }
+ } catch { /* Billing or VIP is unreachable; no extra tier */ }
+ }
+ return { role: 'everyone', signedIn: true };
+}
+
+/** The public channel facts the pressed event and the overlay carry. */
+const channelFacts = (channel) => ({
+ username: channel.username,
+ display_name: channel.display_name || channel.username,
+});
+
+/** The presser's public name, never an email or an account id. */
+const nameOf = (user) => String((user && (user.display_name || user.username)) || 'Someone').slice(0, 120);
+
+/** The element's cost in channel points, from its recipe's conditions. */
+const costOf = (row) => {
+ const c = store.conditionsOf(row).cost;
+ return c && Number.isInteger(c.points) ? c.points : 0;
+};
+
+/**
+ * Send one action to the product that authorises it. Returns { ok, result?, message? }.
+ * `changed` is shared with the caller so one broadcast follows a run that moved the store.
+ */
+async function send({ channel, presser, user, row, element, live, action, pressId, changed }) {
+ switch (action.kind) {
+ case 'screen.counter':
+ case 'screen.state': {
+ const next = await store.applyState(row, action);
+ if (next.changed) changed.revision = next.revision;
+ return { ok: true, result: next };
+ }
+ case 'screen.alert': {
+ const frame = await overlay.emit({
+ streamerId: channel.user_id,
+ kind: 'element',
+ who: { name: nameOf(presser), avatar: presser && presser.avatar_url ? presser.avatar_url : null },
+ text: String(action.title).slice(0, 80),
+ naturalKey: `${pressId}:alert:${action.title}`,
+ });
+ return frame ? { ok: true, result: { id: frame.id } } : { ok: true, result: null };
+ }
+ case 'stream.moment': {
+ const at = elapsedSeconds(live);
+ const n = await db.addTimelineEvents([{
+ stream_id: live.id, user_id: channel.user_id, kind: 'element', start_sec: at,
+ text: `${nameOf(presser)} pressed ${element.name}${action.label ? `: ${String(action.label).slice(0, 120)}` : ''}`,
+ label: String(action.label || element.name).slice(0, 120),
+ }]);
+ return n ? { ok: true, result: { at } } : { ok: false, message: 'The timeline did not take it.' };
+ }
+ case 'chat.message': {
+ const id = await delivery.message({
+ stream_id: live.id, channel_user_id: channel.user_id, user_id: null,
+ username: 'Deck', message: String(action.text).slice(0, 400), message_type: 'system',
+ key: `element:${pressId}:chat`,
+ });
+ return id ? { ok: true, result: { message_id: id } } : { ok: false, message: 'Chat is not available right now.' };
+ }
+ case 'robot.drive_pulse': {
+ const robotId = channel.bot_robot_id;
+ if (!robotId) return { ok: false, message: 'This channel has no robot bound.' };
+ if (!user || !user.id) return { ok: false, message: 'Sign in to drive the robot.' };
+ const subject = await subjectOf(user);
+ if (!subject) return { ok: false, message: 'Sign in with OpenVibe.Network to drive the robot.' };
+ const speed = action.speed == null ? 1 : Number(action.speed);
+ const value = action.direction === 'forward' ? { throttle: speed, steer: 0 }
+ : action.direction === 'back' ? { throttle: -speed, steer: 0 }
+ : action.direction === 'left' ? { throttle: 0, steer: speed }
+ : action.direction === 'right' ? { throttle: 0, steer: -speed }
+ : action.direction === 'spin_left' ? { throttle: 0, steer: speed }
+ : { throttle: 0, steer: -speed };
+ return await robotCommand(robotId, subject, { kind: 'drive', value, ms: action.ms });
+ }
+ case 'robot.head_preset': {
+ const robotId = channel.bot_robot_id;
+ if (!robotId) return { ok: false, message: 'This channel has no robot bound.' };
+ if (!user || !user.id) return { ok: false, message: 'Sign in to move the robot.' };
+ const subject = await subjectOf(user);
+ if (!subject) return { ok: false, message: 'Sign in with OpenVibe.Network to move the robot.' };
+ const angle = ROBOT_PRESETS[String(action.preset).toLowerCase()];
+ if (angle === undefined) return { ok: false, message: `"${String(action.preset).slice(0, 40)}" is not a head preset (up, center or down).` };
+ return await robotCommand(robotId, subject, { kind: 'actuator', value: { name: 'head', value: angle } });
+ }
+ case 'robot.headlight': {
+ const robotId = channel.bot_robot_id;
+ if (!robotId) return { ok: false, message: 'This channel has no robot bound.' };
+ if (!user || !user.id) return { ok: false, message: 'Sign in to use the robot\'s lights.' };
+ const subject = await subjectOf(user);
+ if (!subject) return { ok: false, message: 'Sign in with OpenVibe.Network to use the robot\'s lights.' };
+ // Bot's headlight kind (LOOI headlights, dsh:looi-headlights-1111d) is the authority; before it
+ // ships this is Bot's own unknown-command refusal, never a Live-side substitute.
+ return await robotCommand(robotId, subject, { kind: 'headlight', value: { on: !!action.on } });
+ }
+ case 'robot.say': {
+ const robotId = channel.bot_robot_id;
+ if (!robotId) return { ok: false, message: 'This channel has no robot bound.' };
+ if (!user || !user.id) return { ok: false, message: 'Sign in to make the robot speak.' };
+ const subject = await subjectOf(user);
+ if (!subject) return { ok: false, message: 'Sign in with OpenVibe.Network to make the robot speak.' };
+ return await robotCommand(robotId, subject, { kind: 'say', value: { text: String(action.text).slice(0, 300) } });
+ }
+ default:
+ return { ok: false, message: `${action.kind} is not available in stage 1 of Elements.` };
+ }
+}
+
+async function robotCommand(robotId, subject, body) {
+ try {
+ const answer = await bot.command(robotId, subject, { id: `el_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`, ...body });
+ return { ok: true, result: answer && answer.result !== undefined ? answer.result : null };
+ } catch (e) {
+ if (e instanceof bot.BotRefusal) return { ok: false, message: e.message || 'The robot refused it.', code: e.code };
+ return { ok: false, message: 'The robot is not reachable right now.' };
+ }
+}
+
+const subjectOf = async (user) => {
+ if (!user || !user.id) return null;
+ try { return await identity.subjectOf(user.id); } catch { return null; }
+};
+
+const elapsedSeconds = (live) => {
+ const started = live && live.started_at ? Date.parse(String(live.started_at).includes('T') ? live.started_at : `${String(live.started_at).replace(' ', 'T')}Z`) : NaN;
+ return Number.isFinite(started) ? Math.max(0, Math.round((Date.now() - started) / 1000)) : 0;
+};
+
+/**
+ * Run one element for one fired trigger. Never throws for a refusal; returns
+ * { ok, status, code?, message?, state?, balance?, points_spent?, actions? }.
+ */
+async function run({ channel, presser, row, element, trigger, source = 'press' }) {
+ if (!channel || !Number(channel.deck_enabled)) return refuse('disabled', 'This channel has not opened its deck.');
+ const live = (await db.getLiveStreamsByUserId(channel.user_id) || [])[0];
+ if (!live || !live.is_live) return refuse('offline', 'The deck runs while the channel is live.');
+
+ const needed = (store.conditionsOf(row).role) || 'everyone';
+ const { role } = await roleOf(channel, presser, trigger && trigger.hints ? trigger.hints : {}, needed);
+ const actorName = nameOf(presser);
+ const subject = await subjectOf(presser);
+ const now = Date.now();
+ const pressId = store.newPressKey(element.id, presser && presser.id, now);
+ const changed = { revision: 0 };
+ const points = costOf(row);
+ let spent = 0;
+ let balance = null;
+
+ const stateBefore = Number((await store.stateOf(row)).revision) || 0;
+ const limits = store.limitsFor(row, presser && presser.id, { source, points });
+ const ctx = {
+ now,
+ isLive: true,
+ element: { id: element.id, name: element.name, capabilities: element.capabilities },
+ actor: { subject: subject ? { type: 'user', id: subject } : undefined, role: role || 'everyone', name: actorName },
+ limits,
+ spend: async ({ points: cost }) => {
+ if (!presser || !presser.id) return { ok: false, message: 'Sign in to spend channel points.' };
+ const r = await db.applyChannelPoints({ userId: presser.id, streamerId: channel.user_id, delta: -cost, key: pressId, reason: `element:${String(element.name).slice(0, 80)}` });
+ if (r.applied || r.replayed) { spent = cost; balance = r.balance; return { ok: true, balance: r.balance }; }
+ balance = r.balance;
+ return { ok: false, message: `Not enough channel points (${Number(r.balance) || 0} of ${cost}).` };
+ },
+ refund: async ({ points: cost }) => {
+ if (!presser || !presser.id || !cost) return;
+ try {
+ const r = await db.applyChannelPoints({ userId: presser.id, streamerId: channel.user_id, delta: cost, key: `refund:${pressId}`, reason: `element refund:${String(element.name).slice(0, 70)}` });
+ if (r.applied || r.replayed) { spent = 0; balance = r.balance; }
+ } catch { /* the ledger decides; the refusal is reported either way */ }
+ },
+ send: async (action) => await send({ channel, presser, user: presser, row, element, live, action, pressId, changed }),
+ };
+
+ const result = await store.elementsSdk().run(element.recipe, trigger.trigger, ctx);
+ if (!result.ok) {
+ // A state action that already ran is still true, so viewers get the new store even though the
+ // press as a whole was refused and the points went back. The moment itself is not announced.
+ try {
+ const nowState = await store.stateOf(row);
+ if (Number(nowState.revision) > stateBefore) await announceState({ channel, row, element, state: nowState, live });
+ } catch { /* the refusal is the answer */ }
+ return refuse(result.code, result.message, {
+ retry_after_ms: result.retryAfterMs, scope: result.scope, refused_action: result.refusedAction,
+ refunded: !!result.refunded, points_spent: result.refunded ? 0 : (Number(result.pointsSpent) || 0),
+ });
+ }
+
+ const state = await store.stateOf(row);
+ const stateRevision = Number(state.revision) || 0;
+ if (stateRevision > stateBefore) {
+ await announceState({ channel, row, element, state, live });
+ }
+ const presserSubject = subject ? { type: 'user', id: subject } : null;
+ await announcePress({ channel, row, element, presser, actorName, presserSubject, trigger: trigger.trigger, points: result.pointsSpent || spent, stateRevision, live });
+ if (presser && presser.id) {
+ try { balance = await db.getChannelPoints(presser.id, channel.user_id); } catch { /* the response can go without it */ }
+ }
+ return {
+ ok: true, status: 200, code: null,
+ state: { revision: state.revision, values: state.values },
+ points_spent: result.pointsSpent || 0,
+ balance: balance == null ? null : Number(balance),
+ actions: result.actions.map((a) => ({ kind: a.action.kind, product: a.action.product })),
+ cooldown_ms: await store.cooldownLeft(row, presser && presser.id, Date.now()),
+ };
+}
+
+/** The overlay moment: "Maticus pressed Make it dance" with the public name only. */
+async function announcePress({ channel, row, element, presser, actorName, presserSubject, trigger, points, stateRevision, live }) {
+ const at = new Date().toISOString();
+ try {
+ await overlay.emit({
+ streamerId: channel.user_id,
+ kind: 'element',
+ who: { name: actorName, avatar: presser && presser.avatar_url ? presser.avatar_url : null },
+ text: String(element.name).slice(0, 80),
+ naturalKey: `${row.public_id}:${at}:${actorName}`,
+ });
+ } catch { /* the press happened even if the overlay feed is off */ }
+ try {
+ const atSec = elapsedSeconds(live);
+ await db.addTimelineEvents([{
+ stream_id: live.id, user_id: channel.user_id, kind: 'element', start_sec: atSec,
+ text: `${actorName} pressed ${element.name}${points ? ` (${points} points)` : ''}`,
+ label: String(element.name).slice(0, 120),
+ }]);
+ } catch { /* the timeline is best effort */ }
+ if (!presserSubject) return; // the pressed event's presser is a subject; an unlinked account has none
+ try {
+ streamEvents.init();
+ const envelope = {
+ event_type: 'live.element.pressed',
+ actor: presserSubject,
+ subject: { type: 'element', id: row.public_id },
+ visibility: 'public',
+ priority: 'low',
+ payload: {
+ channel: channelFacts(channel),
+ element_id: row.public_id,
+ element_name: element.name,
+ presser: presserSubject,
+ presser_name: actorName,
+ trigger,
+ points_spent: Number(points) || 0,
+ state_revision: Number(stateRevision) || 0,
+ at,
+ },
+ };
+ await db.getDb().tx(async () => { await streamEvents.enqueue(envelope); });
+ streamEvents.kick();
+ } catch { /* the event is durable when the outbox is on; the press is not repeated for it */ }
+}
+
+/** Broadcast the store to everyone watching, through the same channel room the overlay listens on. */
+async function announceState({ channel, row, element, state, live }) {
+ const payload = {
+ channel: channelFacts(channel), element_id: row.public_id,
+ revision: Number(state.revision) || 0, values: state.values, updated_at: new Date().toISOString(),
+ };
+ try {
+ await delivery.event({ kind: 'channel', id: channel.user_id, stream: live && live.id ? live.id : null },
+ { type: 'element-state', ...payload }, { key: `element-state:${row.public_id}:${payload.revision}` });
+ } catch { /* Chat's ingress may be off in development */ }
+ try {
+ streamEvents.init();
+ await db.getDb().tx(async () => {
+ await streamEvents.enqueue({
+ event_type: 'live.element.state',
+ actor: { type: 'service', id: 'live' },
+ subject: { type: 'element', id: row.public_id },
+ visibility: 'public', priority: 'low', payload,
+ });
+ });
+ streamEvents.kick();
+ } catch { /* durable event only when the outbox is on */ }
+}
+
+/** One press from the deck (POST /api/channels/:name/elements/:id/press). */
+async function fromPress({ channel, presser, id }) {
+ const found = await store.byPublicId(channel.user_id, id);
+ if (!found) return refuse('not_found', 'That element does not exist.');
+ if (!found.element.active) return refuse('not_active', 'That element is not on the deck.');
+ return await run({
+ channel, presser, row: found.row, element: found.element,
+ trigger: { trigger: { kind: 'press' }, hints: {} }, source: 'press',
+ });
+}
+
+/** A chat command from Chat's existing /internal/chat-effects/chat-message effect. */
+async function fromChat({ channelUserId, user, text }) {
+ // Stage 1 takes commands from signed-in people only: it needs a subject for points, roles and
+ // the pressed event, and a bot's own system line must never trigger another element.
+ if (!user || !user.id) return [];
+ const command = String(text || '').trim().split(/\s+/)[0].toLowerCase();
+ if (!/^![a-z0-9_-]{1,32}$/.test(command)) return [];
+ const channel = await db.get('SELECT c.*, u.username, u.display_name FROM channels c JOIN users u ON u.id = c.user_id WHERE c.user_id = ?', [channelUserId]);
+ if (!channel || !Number(channel.deck_enabled)) return [];
+ const byCommand = await store.chatCommands(channel.user_id);
+ const rows = byCommand.get(command) || [];
+ if (!rows.length) return [];
+ const presser = await db.getUserById(user.id);
+ if (!presser) return [];
+ const out = [];
+ for (const row of rows) {
+ const element = store.shape(row, await db.get('SELECT revision, values FROM element_state WHERE element_id = ?', [row.id]));
+ try {
+ out.push({ element_id: row.public_id, ...(await run({
+ channel, presser, row, element,
+ trigger: { trigger: { kind: 'chat_command', command }, hints: { isStreamer: !!user.isStreamer, isMod: !!user.isMod } },
+ source: 'chat',
+ })) });
+ } catch (e) {
+ out.push({ element_id: row.public_id, ok: false, code: 'error', message: 'The element could not run.' });
+ }
+ }
+ return out;
+}
+
+module.exports = { run, fromPress, fromChat, roleOf, costOf, STATUS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,167 @@
@@ -0,0 +1,167 @@
+'use strict';
+/**
+ * Elements, stage 1: the API.
+ *
+ * The streamer's library (signed in):
+ * GET /api/me/elements the library and whether the deck is on
+ * GET /api/me/elements/settings the deck switch
+ * PUT /api/me/elements/settings { enabled } — off until the streamer turns it on
+ * POST /api/me/elements create (validated through openvibe-sdk/elements)
+ * GET /api/me/elements/:id one element
+ * PATCH /api/me/elements/:id name, look, recipe, capabilities, sharing, active, position
+ * DELETE /api/me/elements/:id
+ *
+ * The Viewer Deck on a channel page (public read, per-viewer hints):
+ * GET /api/channels/:name/elements the active elements, their state, what this viewer may press
+ * POST /api/channels/:name/elements/:id/press press one: role, cooldowns, daily caps, channel points, live
+ *
+ * Everything the owner saves goes through the SDK's validate(); everything a press does goes through
+ * the SDK's run() and then the product that owns each action. No route here moves money.
+ */
+const express = require('express');
+const db = require('../db/database');
+const { requireAuth, optionalAuth } = require('../auth/auth');
+const store = require('./store');
+const press = require('./press');
+
+const ownerRouter = express.Router();
+const channelRouter = express.Router();
+
+const fail = (res, err) => {
+ if (err instanceof store.ElementError) {
+ return res.status(err.status).json({ error: err.message, code: err.code, ...(err.errors ? { errors: err.errors } : {}) });
+ }
+ console.warn('[Elements]', err && err.message);
+ return res.status(500).json({ error: 'The element could not be saved right now.', code: 'elements.error' });
+};
+const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
+
+const publicElement = (e) => ({
+ id: e.id,
+ name: e.name,
+ description: e.description,
+ look: e.look,
+ capabilities: e.capabilities,
+ state: e.state,
+});
+
+// ── The streamer's library ───────────────────────────────────────────────────
+ownerRouter.get('/', requireAuth, wrap(async (req, res) => {
+ const [{ enabled }, elements] = await Promise.all([store.settings(req.user.id), store.list(req.user.id)]);
+ res.set('Cache-Control', 'no-store');
+ res.json({ enabled, elements: elements.map((e) => ({ ...e, author: { name: req.user.display_name || req.user.username } })) });
+}));
+
+ownerRouter.get('/settings', requireAuth, wrap(async (req, res) => {
+ res.set('Cache-Control', 'no-store');
+ res.json(await store.settings(req.user.id));
+}));
+
+ownerRouter.put('/settings', requireAuth, wrap(async (req, res) => {
+ if (!req.body || typeof req.body.enabled !== 'boolean') return res.status(400).json({ error: 'enabled is true or false', code: 'element.invalid' });
+ res.json(await store.setEnabled(req.user.id, req.body.enabled));
+}));
+
+ownerRouter.post('/', requireAuth, wrap(async (req, res) => {
+ try {
+ const element = await store.create(req.user, req.body);
+ res.status(201).json({ element });
+ } catch (e) { fail(res, e); }
+}));
+
+ownerRouter.get('/:id', requireAuth, wrap(async (req, res) => {
+ const found = await store.byPublicId(req.user.id, req.params.id);
+ if (!found) return res.status(404).json({ error: 'That element does not exist.', code: 'element.not_found' });
+ res.json({ element: { ...found.element, author: { name: req.user.display_name || req.user.username } } });
+}));
+
+ownerRouter.patch('/:id', requireAuth, wrap(async (req, res) => {
+ try {
+ const element = await store.update(req.user, req.params.id, req.body);
+ res.json({ element });
+ } catch (e) { fail(res, e); }
+}));
+
+ownerRouter.delete('/:id', requireAuth, wrap(async (req, res) => {
+ try {
+ res.json(await store.remove(req.user, req.params.id));
+ } catch (e) { fail(res, e); }
+}));
+
+// ── The Viewer Deck ─────────────────────────────────────────────────────────
+/** What this viewer may press, without running anything: the deck's disabled states come from here. */
+async function deckHints(channel, viewer, elements, live) {
+ const sdk = store.elementsSdk();
+ const need = elements.reduce((highest, { row }) => {
+ const role = store.conditionsOf(row).role || 'everyone';
+ return (sdk.ROLE_RANK[role] || 0) > (sdk.ROLE_RANK[highest] || 0) ? role : highest;
+ }, 'everyone');
+ const role = viewer ? (await press.roleOf(channel, viewer, {}, need)).role : null;
+ const viewerRank = role ? (sdk.ROLE_RANK[role] || 0) : 0;
+ let balance = null;
+ if (viewer) { try { balance = Number(await db.getChannelPoints(viewer.id, channel.user_id)) || 0; } catch { balance = null; } }
+ const now = Date.now();
+ const out = [];
+ for (const { row, element } of elements) {
+ const conditions = store.conditionsOf(row);
+ const needed = conditions.role || 'everyone';
+ const cost = conditions.cost && Number.isInteger(conditions.cost.points) ? conditions.cost.points : 0;
+ let reason = null, message = null, retryAfter = 0;
+ const cooldown = await store.cooldownLeft(row, viewer && viewer.id, now);
+ const today = viewer ? await store.remainingToday(row, viewer.id, now) : { per_person: null, overall: null };
+ if (!viewer) { reason = 'sign_in'; message = 'Sign in to press the deck.'; }
+ else if (!live) { reason = 'offline'; message = 'The deck runs while the channel is live.'; }
+ else if ((sdk.ROLE_RANK[role] || 0) < (sdk.ROLE_RANK[needed] || 0)) { reason = 'role'; message = needed === 'owner' ? 'Only the channel owner can press this.' : `Only ${needed}s can press this.`; }
+ else if (cost && balance != null && balance < cost) { reason = 'points'; message = `It costs ${cost} points; you have ${balance}.`; }
+ else if (cooldown > 0) { reason = 'cooldown'; message = `On cooldown for ${Math.ceil(cooldown / 1000)} more seconds.`; retryAfter = cooldown; }
+ else if (today.per_person === 0 || today.overall === 0) { reason = 'daily_cap'; message = 'Today\'s limit for this element is used up.'; }
+ out.push({
+ ...publicElement(element),
+ cost_points: cost,
+ cooldown_ms: cooldown,
+ daily_remaining: today,
+ can_press: !reason,
+ reason,
+ message,
+ ...(retryAfter ? { retry_after_ms: retryAfter } : {}),
+ });
+ }
+ return { role, balance, elements: out };
+}
+
+channelRouter.get('/:name/elements', optionalAuth, wrap(async (req, res) => {
+ const channel = await store.channelByUsername(req.params.name);
+ if (!channel) return res.status(404).json({ error: 'Channel not found', code: 'channel.not_found' });
+ res.set('Cache-Control', 'no-store');
+ const enabled = !!Number(channel.deck_enabled);
+ const live = (await db.getLiveStreamsByUserId(channel.user_id) || [])[0] || null;
+ const deck = enabled ? await store.deck(channel.user_id) : [];
+ const hints = await deckHints(channel, req.user || null, deck, live);
+ res.json({
+ surface: 'live.deck',
+ channel: { username: channel.username, display_name: channel.display_name || channel.username, avatar_url: channel.avatar_url || null },
+ enabled,
+ live: !!live,
+ stream_id: live ? Number(live.id) : null,
+ viewer: { signed_in: !!req.user, role: hints.role, points: hints.balance },
+ elements: hints.elements,
+ });
+}));
+
+channelRouter.post('/:name/elements/:id/press', requireAuth, wrap(async (req, res) => {
+ const channel = await store.channelByUsername(req.params.name);
+ if (!channel) return res.status(404).json({ error: 'Channel not found', code: 'channel.not_found' });
+ const result = await press.fromPress({ channel, presser: req.user, id: req.params.id });
+ res.set('Cache-Control', 'no-store');
+ if (!result.ok) {
+ if (result.retry_after_ms) res.set('Retry-After', String(Math.max(1, Math.ceil(result.retry_after_ms / 1000))));
+ return res.status(result.status || 400).json({
+ ok: false, error: result.message, code: result.code,
+ ...(result.retry_after_ms ? { retry_after_ms: result.retry_after_ms } : {}),
+ ...(result.refunded ? { refunded: true } : {}),
+ });
+ }
+ res.json(result);
+}));
+
+module.exports = { ownerRouter, channelRouter, publicElement, deckHints };
new file mode 100644
new file mode 100644
@@ -0,0 +1,399 @@
@@ -0,0 +1,399 @@
+'use strict';
+/**
+ * Elements, stage 1: the streamer's library, the store each element keeps, and the presses that
+ * authorise a run (owner ask 2026-11-11; design in ~/openvibe/research-2026-11-11/elements.md).
+ *
+ * An element is a look, a recipe (triggers -> conditions -> actions) and the capabilities it
+ * declares. This module keeps the rows and validates every save through openvibe-sdk/elements, so
+ * the editor, the API and the press path all speak the same language. It never runs an action: the
+ * press path (./press.js) asks Live, Bot and Chat, and each of those checks again.
+ *
+ * list(userId) the owner's library, newest change first
+ * create(userId, body) validate through the SDK, then insert (and its empty state)
+ * update(userId, publicId, body) validate the merged element; the revision moves by one
+ * remove(userId, publicId)
+ * forChannel(userId) the channel row a deck belongs to (created on first save)
+ * channelByUsername(name) the public channel row a viewer's deck names
+ * deck(userId) the active elements in order, with their state
+ * enabled(userId) / setEnabled() the owner switch: off until the streamer turns it on
+ * chatCommands(userId) the channel's chat_command triggers, cached 30 s
+ * stateOf(element) / applyState() the element's own store, through the SDK reducer
+ * limitsFor(element, presserId) cooldowns and daily caps from element_press_log
+ * recordPress(...) the press-log row the limits read
+ */
+const crypto = require('crypto');
+const contracts = require('openvibe-contracts');
+const db = require('../db/database');
+const identity = require('../auth/identity-sync');
+
+const ID_PREFIX = 'el_';
+const CACHE_MS = 30 * 1000;
+const MAX_LOOK_BYTES = 32 * 1024;
+const MAX_RECIPE_BYTES = 48 * 1024;
+
+class ElementError extends Error {
+ constructor(status, code, message, errors) {
+ super(message);
+ this.name = 'ElementError';
+ this.status = status;
+ this.code = code;
+ if (errors) this.errors = errors;
+ }
+}
+
+const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
+const json = (v, d) => {
+ if (v == null) return d;
+ if (typeof v !== 'string') return v;
+ try { return JSON.parse(v); } catch { return d; }
+};
+const iso = (v) => {
+ if (!v) return null;
+ const d = new Date(String(v).includes('T') ? String(v) : `${String(v).replace(' ', 'T')}Z`);
+ return Number.isNaN(d.getTime()) ? null : d.toISOString();
+};
+const publicId = () => ID_PREFIX + contracts.ids.ulid();
+const nowIso = () => new Date().toISOString();
+const dayOf = (ms) => new Date(ms).toISOString().slice(0, 10);
+
+/** openvibe-sdk/elements, loaded lazily so a release that does not have it cannot stop the site booting. */
+let sdk = null;
+let sdkError = null;
+function elementsSdk() {
+ if (sdk) return sdk;
+ if (sdkError) throw new ElementError(503, 'elements_unavailable', 'The element runtime is not available on this release.');
+ try { sdk = require('openvibe-sdk/elements'); } catch (e) { sdkError = e; throw new ElementError(503, 'elements_unavailable', 'The element runtime is not available on this release.'); }
+ return sdk;
+}
+
+// ── Channel and owner ────────────────────────────────────────────────────────
+/** The channel a streamer's library belongs to; a person who has never streamed gets one on first save. */
+async function forChannel(userId) {
+ const channel = await db.getChannelByUserId(userId);
+ return channel || await db.ensureChannel(userId);
+}
+
+async function channelByUsername(username) {
+ const name = String(username || '').trim().replace(/^@/, '');
+ if (!/^[A-Za-z0-9_][A-Za-z0-9_.-]{0,63}$/.test(name)) return null;
+ return await db.getChannelByUsername(name);
+}
+
+async function ownerOf(channel) {
+ return await db.getUserById(channel.user_id);
+}
+
+// ── Reads ────────────────────────────────────────────────────────────────────
+function shape(row, state) {
+ const look = json(row.look, {});
+ const recipe = json(row.recipe, {});
+ const capabilities = json(row.capabilities, []);
+ return {
+ id: row.public_id,
+ revision: Number(row.revision) || 1,
+ name: row.name,
+ description: row.description || undefined,
+ sharing: row.sharing,
+ active: !!Number(row.active),
+ position: Number(row.position) || 0,
+ look,
+ recipe,
+ capabilities,
+ initial_state: json(row.initial_state, {}),
+ mod: json(row.mod, undefined),
+ state: { revision: Number(state && state.revision) || 0, values: json(state && state.values, {}) },
+ created_at: iso(row.created_at),
+ updated_at: iso(row.updated_at),
+ };
+}
+
+/** One stored element, or null. Owner-scoped: another person's id is the same 404 as a missing one. */
+async function byPublicId(userId, id) {
+ if (!/^el_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(id))) return null;
+ const row = await db.get('SELECT * FROM elements WHERE user_id = ? AND public_id = ?', [userId, String(id)]);
+ if (!row) return null;
+ const state = await db.get('SELECT revision, values FROM element_state WHERE element_id = ?', [row.id]);
+ return { row, state, element: shape(row, state) };
+}
+
+/** The owner's whole library, with each element's state and active order. */
+async function list(userId) {
+ const rows = await db.all('SELECT * FROM elements WHERE user_id = ? ORDER BY position ASC, id ASC', [userId]);
+ if (!rows.length) return [];
+ const states = new Map((await db.all(
+ 'SELECT element_id, revision, values FROM element_state WHERE element_id = ANY(?)', [rows.map((r) => r.id)],
+ )).map((s) => [String(s.element_id), s]));
+ return rows.map((r) => shape(r, states.get(String(r.id))));
+}
+
+/** The active elements a viewer's deck shows, in the streamer's order. */
+async function deck(userId) {
+ const rows = await db.all('SELECT * FROM elements WHERE user_id = ? AND active = 1 ORDER BY position ASC, id ASC', [userId]);
+ if (!rows.length) return [];
+ const states = new Map((await db.all(
+ 'SELECT element_id, revision, values FROM element_state WHERE element_id = ANY(?)', [rows.map((r) => r.id)],
+ )).map((s) => [String(s.element_id), s]));
+ return rows.map((r) => ({ row: r, element: shape(r, states.get(String(r.id))) }));
+}
+
+// ── The owner switch ─────────────────────────────────────────────────────────
+async function settings(userId) {
+ const channel = await forChannel(userId);
+ return { enabled: !!Number(channel.deck_enabled) };
+}
+
+async function setEnabled(userId, enabled) {
+ const channel = await forChannel(userId);
+ await db.run('UPDATE channels SET deck_enabled = ? WHERE id = ?', [enabled ? 1 : 0, channel.id]);
+ return { enabled: !!enabled };
+}
+
+// ── Validation through the SDK ────────────────────────────────────────────────
+async function authorFor(user) {
+ const author = { name: String(user.display_name || user.username || 'Someone').slice(0, 80) };
+ try { const subject = await identity.subjectOf(user.id); if (subject) author.subject = { type: 'user', id: subject }; } catch { /* the display name is enough */ }
+ return author;
+}
+
+function checkSize(name, value, max) {
+ const text = JSON.stringify(value);
+ if (Buffer.byteLength(text) > max) throw new ElementError(413, 'element.too_large', `${name} is too large (${Math.ceil(max / 1024)} KB at most).`);
+}
+
+/** Validate the element as the SDK sees it; throws a 400 with the plain reasons when it is not valid. */
+async function validateInput(user, body, { id, revision } = {}) {
+ if (!isObject(body)) throw new ElementError(400, 'element.invalid', 'The element must be a JSON object.');
+ const element = {
+ name: body.name,
+ description: body.description,
+ author: await authorFor(user),
+ sharing: body.sharing || 'private',
+ look: body.look,
+ recipe: body.recipe,
+ capabilities: body.capabilities,
+ initial_state: body.initial_state,
+ mod: body.mod,
+ };
+ if (id) { element.id = id; element.revision = revision; }
+ checkSize('The look', element.look, MAX_LOOK_BYTES);
+ checkSize('The recipe', element.recipe, MAX_RECIPE_BYTES);
+ const result = elementsSdk().validate(element);
+ if (!result.valid) throw new ElementError(400, 'element.invalid', 'That element is not valid.', result.errors);
+ return element;
+}
+
+// ── Writes ────────────────────────────────────────────────────────────────────
+async function create(user, body) {
+ const element = await validateInput(user, body);
+ const channel = await forChannel(user.id);
+ const active = body.active === undefined ? true : !!body.active;
+ const position = Number.isInteger(body.position) ? Math.max(0, Math.min(1000, body.position)) : 0;
+ const id = publicId();
+ const row = await db.get(`INSERT INTO elements
+ (public_id, user_id, name, description, look, recipe, capabilities, initial_state, mod, sharing, active, position)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`, [
+ id, user.id, element.name, element.description || null,
+ JSON.stringify(element.look), JSON.stringify(element.recipe), JSON.stringify(element.capabilities),
+ JSON.stringify(element.initial_state || {}), element.mod ? JSON.stringify(element.mod) : null,
+ element.sharing, active ? 1 : 0, position,
+ ]);
+ await db.run('INSERT INTO element_state (element_id, revision, values) VALUES (?, 0, ?)', [row.id, JSON.stringify(element.initial_state || {})]);
+ invalidateCommands(user.id);
+ return shape(row, { revision: 0, values: JSON.stringify(element.initial_state || {}) });
+}
+
+/** Merge a patch into the stored element, validate the whole thing, then save. */
+async function update(user, id, body) {
+ if (!isObject(body)) throw new ElementError(400, 'element.invalid', 'The element must be a JSON object.');
+ const found = await byPublicId(user.id, id);
+ if (!found) throw new ElementError(404, 'element.not_found', 'That element does not exist.');
+ const current = found.element;
+ const merged = {
+ name: body.name !== undefined ? body.name : current.name,
+ description: body.description !== undefined ? body.description : current.description,
+ sharing: body.sharing !== undefined ? body.sharing : current.sharing,
+ look: body.look !== undefined ? body.look : current.look,
+ recipe: body.recipe !== undefined ? body.recipe : current.recipe,
+ capabilities: body.capabilities !== undefined ? body.capabilities : current.capabilities,
+ initial_state: body.initial_state !== undefined ? body.initial_state : current.initial_state,
+ mod: body.mod !== undefined ? body.mod : current.mod,
+ };
+ const element = await validateInput(user, merged, { id: current.id, revision: current.revision });
+ const active = body.active === undefined ? (current.active ? 1 : 0) : (body.active ? 1 : 0);
+ const position = body.position === undefined ? current.position : Math.max(0, Math.min(1000, Number(body.position) || 0));
+ const row = await db.get(`UPDATE elements SET name = ?, description = ?, look = ?, recipe = ?, capabilities = ?,
+ initial_state = ?, mod = ?, sharing = ?, active = ?, position = ?, revision = revision + 1, updated_at = ov_now()
+ WHERE id = ? AND user_id = ? RETURNING *`, [
+ element.name, element.description || null,
+ JSON.stringify(element.look), JSON.stringify(element.recipe), JSON.stringify(element.capabilities),
+ JSON.stringify(element.initial_state || {}), element.mod ? JSON.stringify(element.mod) : null,
+ element.sharing, active, position, found.row.id, user.id,
+ ]);
+ // A changed initial state is the starting store only while nothing has run; a live counter is never reset.
+ if (body.initial_state !== undefined && Number(found.state && found.state.revision) === 0) {
+ await db.run('UPDATE element_state SET values = ? WHERE element_id = ?', [JSON.stringify(element.initial_state || {}), found.row.id]);
+ }
+ invalidateCommands(user.id);
+ const state = await db.get('SELECT revision, values FROM element_state WHERE element_id = ?', [found.row.id]);
+ return shape(row, state);
+}
+
+async function remove(user, id) {
+ const found = await byPublicId(user.id, id);
+ if (!found) throw new ElementError(404, 'element.not_found', 'That element does not exist.');
+ await db.run('DELETE FROM elements WHERE id = ? AND user_id = ?', [found.row.id, user.id]);
+ invalidateCommands(user.id);
+ return { removed: true, id: found.row.public_id };
+}
+
+// ── The element's own store ───────────────────────────────────────────────────
+async function stateOf(row) {
+ const state = await db.get('SELECT revision, values FROM element_state WHERE element_id = ?', [row.id]);
+ return { revision: Number(state && state.revision) || 0, values: json(state && state.values, {}) };
+}
+
+/**
+ * Apply one screen.counter or screen.state action through the SDK reducer and persist it with an
+ * optimistic revision check. Returns the new { revision, values } or throws a refusal.
+ */
+async function applyState(row, action) {
+ for (let attempt = 0; attempt < 2; attempt++) {
+ const current = await stateOf(row);
+ const next = elementsSdk().reduce(current, [action]);
+ if (next.errors && next.errors.length) throw new ElementError(400, 'element.invalid_state', next.errors[0].message, next.errors);
+ if (!next.changed) return { revision: current.revision, values: current.values, changed: false };
+ const updated = await db.run('UPDATE element_state SET revision = ?, values = ?, updated_at = ov_now() WHERE element_id = ? AND revision = ?',
+ [next.revision, JSON.stringify(next.values), row.id, current.revision]);
+ if (Number(updated.changes) > 0) return { revision: next.revision, values: next.values, changed: true };
+ }
+ throw new ElementError(409, 'element.state_conflict', 'The element changed at the same time; try again.');
+}
+
+// ── Limits: cooldowns and daily caps, from the press log ─────────────────────
+const LIMIT_COL = { person: 'presser_id = ?', overall: '1 = 1' };
+
+/** The element's conditions, as the SDK reads them. */
+function conditionsOf(row) {
+ const recipe = json(row.recipe, {});
+ return isObject(recipe.conditions) ? recipe.conditions : {};
+}
+
+function limitsFor(row, presserId, { source = 'press', points = 0 } = {}) {
+ const conditions = conditionsOf(row);
+ const cooldown = conditions.cooldown || {};
+ const cap = conditions.daily_cap || {};
+ return {
+ async cooldown({ scope, at }) {
+ const seconds = Number(scope === 'person' ? cooldown.per_person_seconds : cooldown.overall_seconds) || 0;
+ if (!seconds) return 0;
+ const where = LIMIT_COL[scope];
+ const args = scope === 'person' ? [row.id, presserId] : [row.id];
+ const last = await db.get(`SELECT MAX(created_at) AS at FROM element_press_log WHERE element_id = ? AND ${where}`, args);
+ const lastMs = last && last.at ? Date.parse(String(last.at).includes('T') ? last.at : `${String(last.at).replace(' ', 'T')}Z`) : 0;
+ if (!lastMs) return 0;
+ return Math.max(0, lastMs + seconds * 1000 - at);
+ },
+ async dailyCount({ scope, day }) {
+ const max = scope === 'person' ? cap.per_person : cap.overall;
+ if (!max) return 0;
+ const where = LIMIT_COL[scope];
+ const args = scope === 'person' ? [row.id, presserId] : [row.id];
+ const start = `${day}T00:00:00.000Z`;
+ const rowCount = await db.get(`SELECT COUNT(*) AS n FROM element_press_log WHERE element_id = ? AND ${where} AND created_at >= ?`, [...args, start]);
+ return Number(rowCount && rowCount.n) || 0;
+ },
+ async hit({ at }) {
+ await recordPress({ row, presserId, source, points, at });
+ },
+ };
+}
+
+/**
+ * The press-log row: the per-person/overall cooldown evidence, the daily counter and the moment's
+ * own date. `at` is milliseconds; the log keeps UTC ISO text like the rest of the schema.
+ */
+async function recordPress({ row, presserId, source, points, at }) {
+ const when = new Date(Number.isFinite(at) ? at : Date.now()).toISOString();
+ await db.run('INSERT INTO element_press_log (element_id, presser_id, source, points, created_at) VALUES (?, ?, ?, ?, ?)',
+ [row.id, presserId || null, source === 'chat' ? 'chat' : 'press', Math.max(0, Number(points) || 0), when]);
+}
+
+/** How many times this presser (or anyone) may still fire today, for the deck's hint. */
+async function remainingToday(row, presserId, at) {
+ const cap = conditionsOf(row).daily_cap || {};
+ const day = dayOf(at);
+ const out = { per_person: null, overall: null };
+ if (cap.per_person) {
+ const r = await db.get('SELECT COUNT(*) AS n FROM element_press_log WHERE element_id = ? AND presser_id = ? AND created_at >= ?', [row.id, presserId, `${day}T00:00:00.000Z`]);
+ out.per_person = Math.max(0, Number(cap.per_person) - (Number(r && r.n) || 0));
+ }
+ if (cap.overall) {
+ const r = await db.get('SELECT COUNT(*) AS n FROM element_press_log WHERE element_id = ? AND created_at >= ?', [row.id, `${day}T00:00:00.000Z`]);
+ out.overall = Math.max(0, Number(cap.overall) - (Number(r && r.n) || 0));
+ }
+ return out;
+}
+
+async function cooldownLeft(row, presserId, at) {
+ const cooldown = conditionsOf(row).cooldown || {};
+ let left = 0;
+ for (const scope of ['person', 'overall']) {
+ const seconds = Number(scope === 'person' ? cooldown.per_person_seconds : cooldown.overall_seconds) || 0;
+ if (!seconds) continue;
+ const args = scope === 'person' ? [row.id, presserId] : [row.id];
+ const last = await db.get(`SELECT MAX(created_at) AS at FROM element_press_log WHERE element_id = ? AND ${LIMIT_COL[scope]}`, args);
+ const lastMs = last && last.at ? Date.parse(String(last.at).includes('T') ? last.at : `${String(last.at).replace(' ', 'T')}Z`) : 0;
+ if (lastMs) left = Math.max(left, lastMs + seconds * 1000 - at);
+ }
+ return left;
+}
+
+/** A stable key for one press's points debit and its refund. */
+const newPressKey = (elementId, presserId, at) => `element:${elementId}:${presserId || 'anon'}:${at}:${crypto.randomBytes(8).toString('hex')}`;
+
+// ── The chat_command trigger cache ────────────────────────────────────────────
+const commandCache = new Map(); // user id -> { at, byCommand: Map<command, element public id> }
+
+function commandsFor(userId) {
+ return commandCache.get(String(userId)) || null;
+}
+
+function invalidateCommands(userId) {
+ commandCache.delete(String(userId));
+}
+
+/**
+ * The channel's active elements that listen for a chat command, keyed by the command. Cached 30 s
+ * and invalidated on every save, so a busy chat does not read the library per message.
+ */
+async function chatCommands(userId) {
+ const key = String(userId);
+ const hit = commandCache.get(key);
+ if (hit && Date.now() - hit.at < CACHE_MS) return hit.byCommand;
+ const rows = await db.all("SELECT * FROM elements WHERE user_id = ? AND active = 1 AND recipe::text LIKE '%chat_command%'", [userId]);
+ const byCommand = new Map();
+ for (const row of rows) {
+ const recipe = json(row.recipe, {});
+ for (const trigger of Array.isArray(recipe.triggers) ? recipe.triggers : []) {
+ if (trigger && trigger.kind === 'chat_command' && /^![a-z0-9_-]{1,32}$/.test(String(trigger.command))) {
+ const command = String(trigger.command).toLowerCase();
+ if (!byCommand.has(command)) byCommand.set(command, []);
+ byCommand.get(command).push(row);
+ }
+ }
+ }
+ commandCache.set(key, { at: Date.now(), byCommand });
+ if (commandCache.size > 5000) commandCache.clear();
+ return byCommand;
+}
+
+module.exports = {
+ ElementError, elementsSdk,
+ forChannel, channelByUsername, ownerOf,
+ byPublicId, list, deck, shape, json,
+ settings, setEnabled,
+ create, update, remove,
+ stateOf, applyState, conditionsOf, limitsFor, recordPress, remainingToday, cooldownLeft, newPressKey,
+ chatCommands, invalidateCommands,
+ dayOf,
+};
@@ -826,6 +826,7 @@ app.use('/api/auth', authRoutes);
@@ -826,6 +826,7 @@ app.use('/api/auth', authRoutes);
// The signed-in person's picture-in-picture layout, in their Network account (server/me/routes.js); 404 until the
// The signed-in person's picture-in-picture layout, in their Network account (server/me/routes.js); 404 until the
// pinned contracts carry live.layout, so the browser's own copy keeps working.
// pinned contracts carry live.layout, so the browser's own copy keeps working.
app.use('/api/me', require('./me/routes').createLayoutRouter());
app.use('/api/me', require('./me/routes').createLayoutRouter());
+app.use('/api/me/elements', require('./elements/routes').ownerRouter); // the streamer's element library (Elements, stage 1)
app.use('/api/bench-stream', require('./bench-stream/routes'));
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'));
@@ -905,6 +906,7 @@ app.use('/api/ai-viewers', require('./ai/viewers/routes'));
@@ -905,6 +906,7 @@ app.use('/api/ai-viewers', require('./ai/viewers/routes'));
app.use('/api/vibe', require('./ai/vibe-routes'));
app.use('/api/vibe', require('./ai/vibe-routes'));
app.use('/api/powerchat', require('./integrations/powerchat-routes'));
app.use('/api/powerchat', require('./integrations/powerchat-routes'));
app.use('/api/overlay', require('./overlay/routes')); // the overlay alert feed: the streamer's own test alert
app.use('/api/overlay', require('./overlay/routes')); // the overlay alert feed: the streamer's own test alert
+app.use('/api/channels', require('./elements/routes').channelRouter); // the Viewer Deck and its presses (Elements, stage 1)
app.use('/api/meta', metaRoutes);
app.use('/api/meta', metaRoutes);
app.use('/api/pastes', pasteRoutes);
app.use('/api/pastes', pasteRoutes);
app.use('/api/content', require('./content/routes')); // Content (people's work) + Moments (AI) feeds
app.use('/api/content', require('./content/routes')); // Content (people's work) + Moments (AI) feeds
@@ -11,7 +11,7 @@
@@ -11,7 +11,7 @@
*
*
* { type: 'overlay-alert', kind, id, at, who: { name, avatar }|null, amount: { value, unit }|null, text }
* { type: 'overlay-alert', kind, id, at, who: { name, avatar }|null, amount: { value, unit }|null, text }
*
*
- * kind follow | tip | sub | raid | goal
+ * kind follow | tip | sub | raid | goal | element
* id unique per moment (for de-duplication on the overlay); a hash of the moment's natural key,
* id unique per moment (for de-duplication on the overlay); a hash of the moment's natural key,
* so it says nothing about who tipped, how much, or which account it was
* so it says nothing about who tipped, how much, or which account it was
* at when the moment happened (ISO 8601)
* at when the moment happened (ISO 8601)
@@ -33,7 +33,7 @@
@@ -33,7 +33,7 @@
const crypto = require('crypto');
const crypto = require('crypto');
const delivery = require('../chat/chat-delivery');
const delivery = require('../chat/chat-delivery');
-const KINDS = ['follow', 'tip', 'sub', 'raid', 'goal'];
+const KINDS = ['follow', 'tip', 'sub', 'raid', 'goal', 'element'];
const UNITS = ['vibes', 'usd', 'viewers'];
const UNITS = ['vibes', 'usd', 'viewers'];
const TEXT_MAX = 200;
const TEXT_MAX = 200;
const NAME_MAX = 120;
const NAME_MAX = 120;
@@ -25,6 +25,7 @@ const SAMPLES = {
@@ -25,6 +25,7 @@ const SAMPLES = {
sub: { who: { name: 'Test Subscriber', avatar: null }, amount: null, text: null },
sub: { who: { name: 'Test Subscriber', avatar: null }, amount: null, text: null },
raid: { who: { name: 'Test Raider', avatar: null }, amount: { value: 12, unit: 'viewers' }, text: null },
raid: { who: { name: 'Test Raider', avatar: null }, amount: { value: 12, unit: 'viewers' }, text: null },
goal: { who: null, amount: { value: 1000, unit: 'vibes' }, text: null },
goal: { who: null, amount: { value: 1000, unit: 'vibes' }, text: null },
+ element: { who: { name: 'Test Viewer', avatar: null }, amount: null, text: 'Make it dance' },
};
};
// A few a minute, per streamer.
// A few a minute, per streamer.
new file mode 100644
new file mode 100644
@@ -0,0 +1,39 @@
@@ -0,0 +1,39 @@
+'use strict';
+/**
+ * The Viewer Deck's page wiring, without a browser: the channel fragment carries the two `live.deck`
+ * containers (live and offline), the channel feature loads the deck script, the script speaks to the
+ * deck API and the channel's element-state frames, the stylesheet places the deck beside the player on
+ * a computer and under it below 1180 px, and the OBS alerts page knows the element moment. The API and
+ * the presses themselves are test/elements.test.js; this is the part a server test cannot see.
+ */
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+
+const ROOT = path.join(__dirname, '..');
+const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
+
+const fragment = read('public/fragments/channel.html');
+assert.strictEqual((fragment.match(/<aside class="ch-deck"/g) || []).length, 2, 'a live deck and an offline deck');
+assert.strictEqual((fragment.match(/data-surface="live\.deck"/g) || []).length, 1, 'the live deck names the live.deck surface');
+assert.match(fragment, /id="ch-deck"/);
+assert.match(fragment, /id="ch-deck-offline"/);
+
+const features = JSON.parse(read('public/features.json'));
+assert.ok(features.features.channel.js.includes('/js/viewer-deck.js'), 'the channel feature loads the deck script');
+
+const deck = read('public/js/viewer-deck.js');
+assert.match(deck, /\/channels\/' \+ encodeURIComponent\(/);
+assert.match(deck, /element-state/);
+assert.match(deck, /ovk-btn/);
+assert.match(deck, /cannot|Not available|Sign in|While /);
+
+const css = read('public/css/features/channel.css');
+assert.match(css, /\.ch-deck \{/);
+assert.match(css, /@media \(max-width: 1180px\)/);
+
+const alerts = read('public/obs/alerts.html');
+assert.match(alerts, /element: \{ icon:/, 'the OBS alerts page draws an element moment');
+
+console.log('elements page: the deck markup, script and styles are wired');
+process.exit(0);
new file mode 100644
new file mode 100644
@@ -0,0 +1,388 @@
@@ -0,0 +1,388 @@
+'use strict';
+/**
+ * Elements, stage 1: the library, the deck and every refusal on the press path.
+ *
+ * node --import ./test/helpers/pg-preload.mjs test/elements.test.js
+ *
+ * The products are stubs: Bot records its command, Chat records its message, the overlay records its
+ * moment. What is real here is Live's own authorisation: the owner switch, live-only, the viewer's
+ * role, cooldowns, daily caps, channel points, the declared capabilities, the state reducer and the
+ * refund when a product refuses.
+ */
+const assert = require('assert');
+
+process.env.NODE_ENV = 'test';
+const quiet = console.log;
+console.log = () => {};
+console.warn = () => {};
+
+(async () => {
+ const db = require('../server/db/database');
+ await db.initDb();
+ const raw = db.getDb();
+
+ const addUser = async (id, username, display) => await raw.prepare(
+ `INSERT INTO users (id, username, display_name, email, password_hash, role) OVERRIDING SYSTEM VALUE
+ VALUES (?, ?, ?, ?, 'x', 'user')`).run(id, username, display, `${username}@example.test`);
+ await addUser(2, 'ann', 'Ann');
+ await addUser(3, 'bob', 'Bob');
+ await addUser(4, 'cid', 'Cid');
+ const ANN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNA1';
+ const BOB = 'usr_01JAB2C3D4E5F6G7H8J9K0MNB2';
+ const CID = 'usr_01JAB2C3D4E5F6G7H8J9K0MNC3';
+ await raw.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (2, 'network', '102', ?), (3, 'network', '103', ?), (4, 'network', '104', ?)")
+ .run(ANN, BOB, CID);
+ const channel = await db.ensureChannel(2);
+ const streamId = Number((await raw.prepare("INSERT INTO streams (user_id, channel_id, title, protocol, is_live, started_at) VALUES (2, ?, 'Ann live', 'webrtc', 1, ?) RETURNING id")
+ .run(channel.id, new Date(Date.now() - 60_000).toISOString())).lastInsertRowid);
+ await raw.prepare("INSERT INTO follows (follower_id, streamer_id) VALUES (3, 2)").run();
+ await db.applyChannelPoints({ userId: 3, streamerId: 2, delta: 100, key: 'seed:bob' });
+
+ // OpenVibe.Bot and OpenVibe.Chat are stubs; Live is real.
+ const delivery = require('../server/chat/chat-delivery');
+ const overlay = require('../server/overlay/overlay-alerts');
+ const bot = require('../server/bot/bot-client');
+ const permissions = require('../server/auth/permissions');
+ const billing = require('../server/monetization/billing-actions');
+ const streamEvents = require('../server/events/stream-events');
+ const sent = { chat: [], events: [], alerts: [], bots: [], envelopes: [] };
+ delivery.message = async (body) => { sent.chat.push(body); return 900 + sent.chat.length; };
+ delivery.event = async (target, frame, opts) => { sent.events.push({ target, frame, opts }); return {}; };
+ overlay.emit = async (args) => { sent.alerts.push(args); return { id: `a${sent.alerts.length}` }; };
+ bot.command = async (...args) => { sent.bots.push(args); if (sent.botRefuse) throw new bot.BotRefusal('bot.not_an_operator', 'you are not an operator on this robot', 403); return { result: { ok: true } }; };
+ bot.role = async (robotId, subject) => (subject === BOB ? 'operator' : null);
+ permissions.isChannelModerator = async () => false;
+ billing.entitlementOf = async () => ({ active: false, via: 'billing' });
+ db.isActiveSubscriber = async () => false;
+ streamEvents.enqueue = async (env) => { sent.envelopes.push(env); return env; };
+ streamEvents.init = () => null;
+ streamEvents.kick = () => {};
+
+ const store = require('../server/elements/store');
+ const press = require('../server/elements/press');
+ const ann = { id: 2, username: 'ann', display_name: 'Ann' };
+ const bob = { id: 3, username: 'bob', display_name: 'Bob', avatar_url: null };
+ const cid = { id: 4, username: 'cid', display_name: 'Cid', avatar_url: null };
+ const reset = () => { sent.chat.length = 0; sent.events.length = 0; sent.alerts.length = 0; sent.bots.length = 0; sent.envelopes.length = 0; sent.botRefuse = false; };
+
+ const look = (label = 'Dance') => ({ component: 'button', label });
+ const recipe = (over = {}) => ({
+ triggers: [{ kind: 'press' }],
+ conditions: {},
+ actions: [{ kind: 'screen.counter', product: 'live', key: 'dances', by: 1 }],
+ ...over,
+ });
+ const make = async (over = {}) => await store.create(ann, {
+ name: over.name || 'Make it dance',
+ look: over.look || look(),
+ recipe: over.recipe || recipe(),
+ capabilities: over.capabilities || ['screen.counter'],
+ initial_state: over.initial_state || { dances: 0 },
+ sharing: 'private',
+ active: over.active === undefined ? true : over.active,
+ position: over.position,
+ });
+
+ // ── The library validates through the SDK ─────────────────────────────────
+ {
+ const element = await make();
+ assert.match(element.id, /^el_/);
+ assert.equal(element.revision, 1);
+ assert.deepEqual(store.elementsSdk().validate({ name: element.name, author: { name: 'Ann' }, sharing: 'private', look: element.look, recipe: element.recipe, capabilities: element.capabilities }).valid, true);
+ await assert.rejects(
+ store.create(ann, { name: 'Bad', look: look(), recipe: recipe(), capabilities: ['screen.alert'] }),
+ (e) => e.status === 400 && e.errors.some((x) => /did not declare/.test(x.message)),
+ 'a recipe action the element did not declare is refused at save',
+ );
+ await assert.rejects(store.create(ann, { name: 'Bad', look: look(), recipe: recipe({ triggers: [{ kind: 'cheer' }] }), capabilities: ['screen.counter'] }), (e) => e.status === 400);
+ const updated = await store.update(ann, element.id, { name: 'Make it dance now', position: 2 });
+ assert.equal(updated.name, 'Make it dance now');
+ assert.equal(updated.revision, 2);
+ const list = await store.list(2);
+ assert.ok(list.some((x) => x.id === element.id));
+ }
+
+ // ── The owner switch is off until the streamer turns it on ───────────────
+ assert.deepEqual(await store.settings(2), { enabled: false });
+ const offDeck = await press.fromPress({ channel: await store.channelByUsername('ann'), presser: bob, id: (await store.list(2))[0].id });
+ assert.equal(offDeck.code, 'disabled');
+ await store.setEnabled(2, true);
+ assert.deepEqual(await store.settings(2), { enabled: true });
+
+ // ── The deck: active elements only, in order, with their state ───────────
+ {
+ const second = await make({ name: 'Second', position: 1 });
+ const hidden = await make({ name: 'Hidden', position: 2, active: false });
+ const deck = await store.deck(2);
+ assert.ok(deck.length >= 2);
+ const ids = deck.map((x) => x.element.id);
+ assert.ok(ids.includes(second.id) && !ids.includes(hidden.id));
+ assert.ok(deck.every((x) => x.element.state && typeof x.element.state.values === 'object'));
+ }
+
+ // ── Live-only ────────────────────────────────────────────────────────────
+ {
+ const element = await make({ name: 'Live only', position: 10 });
+ await raw.prepare('UPDATE streams SET is_live = 0, ended_at = ov_now() WHERE id = ?').run(streamId);
+ let r = await press.fromPress({ channel: await store.channelByUsername('ann'), presser: bob, id: element.id });
+ assert.equal(r.code, 'offline');
+ assert.equal(r.status, 409);
+ assert.equal(sent.alerts.length, 0);
+ await raw.prepare('UPDATE streams SET is_live = 1, ended_at = NULL, started_at = ? WHERE id = ?').run(new Date(Date.now() - 60_000).toISOString(), streamId);
+ r = await press.fromPress({ channel: await store.channelByUsername('ann'), presser: bob, id: element.id });
+ assert.equal(r.ok, true);
+ }
+
+ // ── Role: a follower-only element refuses a non-follower ─────────────────
+ {
+ reset();
+ const element = await make({ name: 'Followers only', position: 20, recipe: recipe({ conditions: { role: 'follower' } }) });
+ let r = await press.fromPress({ channel: await store.channelByUsername('ann'), presser: cid, id: element.id });
+ assert.equal(r.code, 'role');
+ assert.equal(r.status, 403);
+ assert.equal(sent.alerts.length, 0);
+ r = await press.fromPress({ channel: await store.channelByUsername('ann'), presser: bob, id: element.id });
+ assert.equal(r.ok, true, 'a follower gets through');
+ }
+
+ // ── Cost: checked by Live's channel-points ledger, and refunded ──────────
+ {
+ reset();
+ const have = Number(await db.getChannelPoints(3, 2)) || 0;
+ await db.applyChannelPoints({ userId: 3, streamerId: 2, delta: 100 - have, key: 'reset:bob' });
+ const element = await make({ name: 'Costs 50', position: 30, recipe: recipe({ conditions: { cost: { points: 50 } } }) });
+ let r = await press.fromPress({ channel: await store.channelByUsername('ann'), presser: bob, id: element.id });
+ assert.equal(r.ok, true);
+ assert.equal(r.points_spent, 50);
+ assert.equal(r.balance, 50);
+ assert.equal(await db.getChannelPoints(3, 2), 50);
+ // Not enough: the owner refuses before any action runs.
+ await db.applyChannelPoints({ userId: 3, streamerId: 2, delta: -45, key: 'spend:bob' }); // 5 left
+ reset();
+ r = await press.fromPress({ channel: await store.channelByUsername('ann'), presser: bob, id: element.id });
+ assert.equal(r.code, 'points');
+ assert.equal(r.status, 402);
+ assert.equal(sent.alerts.length, 0);
+ // A product's refusal refunds the points.
+ await db.applyChannelPoints({ userId: 3, streamerId: 2, delta: 95, key: 'topup2:bob' });
+ delivery.message = async () => null;
+ const chained = await make({
+ name: 'Counter then chat', position: 31, capabilities: ['screen.counter', 'chat.message'],
+ recipe: recipe({
+ conditions: { cost: { points: 10 }, cooldown: { per_person_seconds: 0 } },
+ actions: [
+ { kind: 'screen.counter', product: 'live', key: 'dances', by: 1 },
+ { kind: 'chat.message', product: 'chat', text: 'the deck says hi' },
+ ],
+ }),
+ });
+ reset();
+ r = await press.fromPress({ channel: await store.channelByUsername('ann'), presser: bob, id: chained.id });
+ assert.equal(r.code, 'action');
+ assert.equal(r.refunded, true);
+ assert.equal(r.points_spent, 0);
+ assert.equal(await db.getChannelPoints(3, 2), 100, 'a refused action gives the points back');
+ delivery.message = async (body) => { sent.chat.push(body); return 900 + sent.chat.length; };
+ }
+
+ // ── Cooldown and daily cap ───────────────────────────────────────────────
+ {
+ reset();
+ const element = await make({ name: 'Slow', position: 40, recipe: recipe({ conditions: { cooldown: { per_person_seconds: 60 } } }) });
+ const channel = await store.channelByUsername('ann');
+ let r = await press.fromPress({ channel, presser: bob, id: element.id });
+ assert.equal(r.ok, true);
+ r = await press.fromPress({ channel, presser: bob, id: element.id });
+ assert.equal(r.code, 'cooldown');
+ assert.equal(r.status, 429);
+ assert.ok(r.retry_after_ms > 50000 && r.retry_after_ms <= 60000, `retry after ${r.retry_after_ms}`);
+ const capped = await make({ name: 'Once a day', position: 41, recipe: recipe({ conditions: { daily_cap: { per_person: 1 } } }) });
+ r = await press.fromPress({ channel, presser: bob, id: capped.id });
+ assert.equal(r.ok, true);
+ r = await press.fromPress({ channel, presser: bob, id: capped.id });
+ assert.equal(r.code, 'daily_cap');
+ assert.equal(r.status, 429);
+ }
+
+ // ── The state reducer, the press moment, the timeline and the events ─────
+ {
+ reset();
+ const element = await make({ name: 'Dance', position: 50 });
+ const before = store.shape((await store.byPublicId(2, element.id)).row, { revision: 0, values: { dances: 0 } });
+ assert.equal(before.state.revision, 0);
+ const r = await press.fromPress({ channel: await store.channelByUsername('ann'), presser: bob, id: element.id });
+ assert.equal(r.ok, true);
+ assert.deepEqual(r.state, { revision: 1, values: { dances: 1 } });
+ assert.equal(sent.alerts.length, 1);
+ assert.equal(sent.alerts[0].kind, 'element');
+ assert.equal(sent.alerts[0].who.name, 'Bob');
+ assert.equal(sent.alerts[0].text, 'Dance');
+ const timeline = await raw.prepare("SELECT kind, text, start_sec FROM stream_timeline_events WHERE stream_id = ? AND kind = 'element' ORDER BY id DESC LIMIT 1").get(streamId);
+ assert.equal(timeline.kind, 'element');
+ assert.match(timeline.text, /Bob pressed Dance/);
+ assert.ok(timeline.start_sec >= 55);
+ const stateFrame = sent.events.find((e) => e.frame && e.frame.type === 'element-state');
+ assert.ok(stateFrame, 'the new state goes to the channel room');
+ assert.equal(stateFrame.frame.revision, 1);
+ assert.deepEqual(stateFrame.frame.values, { dances: 1 });
+ assert.ok(sent.envelopes.some((e) => e.event_type === 'live.element.pressed' && e.payload.element_name === 'Dance' && e.payload.presser_name === 'Bob'));
+ assert.ok(sent.envelopes.some((e) => e.event_type === 'live.element.state' && e.payload.revision === 1));
+ }
+
+ // ── An undeclared capability is refused before anything runs ─────────────
+ {
+ reset();
+ const row = await raw.prepare(`INSERT INTO elements (public_id, user_id, name, look, recipe, capabilities, initial_state, sharing, active, position)
+ VALUES (?, 2, 'Sneaky', ?, ?, ?, '{}'::jsonb, 'private', 1, 60) RETURNING *`)
+ .get('el_01JAB2C3D4E5F6G7H8J9K0MND4', JSON.stringify(look('Sneaky')), JSON.stringify(recipe()), JSON.stringify(['screen.alert']));
+ await raw.prepare('INSERT INTO element_state (element_id, revision, values) VALUES (?, 0, ?)').run(row.id, '{}');
+ const r = await press.fromPress({ channel: await store.channelByUsername('ann'), presser: bob, id: 'el_01JAB2C3D4E5F6G7H8J9K0MND4' });
+ assert.equal(r.code, 'capability');
+ assert.equal(r.status, 403);
+ assert.equal(sent.alerts.length, 0);
+ assert.equal(sent.bots.length, 0);
+ }
+
+ // ── Operator-only elements: Bot's own operators count ───────────────────
+ {
+ reset();
+ await raw.prepare('UPDATE channels SET bot_robot_id = ? WHERE id = ?').run('rob_01J8Z4M2Q0R7T9YV3K6N8P1W2X', channel.id);
+ const op = await make({ name: 'Operators only', position: 60, recipe: recipe({ conditions: { role: 'operator' } }) });
+ let r = await press.fromPress({ channel: await store.channelByUsername('ann'), presser: bob, id: op.id });
+ assert.equal(r.ok, true, 'a Bot operator may press an operator element');
+ r = await press.fromPress({ channel: await store.channelByUsername('ann'), presser: cid, id: op.id });
+ assert.equal(r.code, 'role');
+ await raw.prepare('UPDATE channels SET bot_robot_id = NULL WHERE id = ?').run(channel.id);
+ }
+
+ // ── Robot actions go through Bot's command path, and the turn rule ───────
+ {
+ reset();
+ await raw.prepare('UPDATE channels SET bot_robot_id = ? WHERE id = ?').run('rob_01J8Z4M2Q0R7T9YV3K6N8P1W2X', channel.id);
+ const say = await make({
+ name: 'Say hi', position: 70, capabilities: ['robot.say'],
+ recipe: recipe({ actions: [{ kind: 'robot.say', product: 'bot', text: 'hello chat' }] }),
+ });
+ let r = await press.fromPress({ channel: await store.channelByUsername('ann'), presser: bob, id: say.id });
+ assert.equal(r.ok, true, JSON.stringify(r));
+ assert.equal(sent.bots.length, 1);
+ assert.equal(sent.bots[0][0], 'rob_01J8Z4M2Q0R7T9YV3K6N8P1W2X');
+ assert.equal(sent.bots[0][1], BOB);
+ assert.equal(sent.bots[0][2].kind, 'say');
+ // Bot's refusal is a refusal, not a success.
+ sent.botRefuse = true;
+ r = await press.fromPress({ channel: await store.channelByUsername('ann'), presser: bob, id: say.id });
+ assert.equal(r.code, 'action');
+ assert.match(r.message, /not an operator/);
+ // robot_turn_only with no turn answer refuses before Bot is asked.
+ reset();
+ const turnOnly = await make({
+ name: 'Only my turn', position: 71, capabilities: ['robot.say'],
+ recipe: recipe({ conditions: { robot_turn_only: true }, actions: [{ kind: 'robot.say', product: 'bot', text: 'hello' }] }),
+ });
+ r = await press.fromPress({ channel: await store.channelByUsername('ann'), presser: bob, id: turnOnly.id });
+ assert.equal(r.code, 'robot_turn');
+ assert.equal(sent.bots.length, 0);
+ await raw.prepare('UPDATE channels SET bot_robot_id = NULL WHERE id = ?').run(channel.id);
+ }
+
+ // ── A chat command through Chat's own effect path ────────────────────────
+ {
+ reset();
+ const dance = await make({
+ name: 'Dance command', position: 80, recipe: recipe({ triggers: [{ kind: 'chat_command', command: '!dance' }] }),
+ });
+ const none = await press.fromChat({ channelUserId: 2, user: { id: 3 }, text: 'hello there' });
+ assert.deepEqual(none, []);
+ const results = await press.fromChat({ channelUserId: 2, user: { id: 3 }, text: '!dance please' });
+ assert.equal(results.length, 1);
+ assert.equal(results[0].ok, true);
+ assert.equal(results[0].element_id, dance.id);
+ assert.deepEqual(results[0].state.values, { dances: 1 });
+ const anon = await press.fromChat({ channelUserId: 2, user: { id: null }, text: '!dance' });
+ assert.deepEqual(anon, [], 'anonymous chat cannot spend or trigger in stage 1');
+ void none;
+ }
+
+ // ── The owner can switch the deck off again ──────────────────────────────
+ {
+ await store.setEnabled(2, false);
+ const r = await press.fromPress({ channel: await store.channelByUsername('ann'), presser: bob, id: (await store.list(2))[0].id });
+ assert.equal(r.code, 'disabled');
+ }
+
+ // ── The HTTP API: the owner's library, the public deck, the press ────────
+ {
+ const auth = require('../server/auth/auth');
+ auth.requireAuth = (req, res, next) => {
+ const id = Number(req.headers['x-test-user'] || 0);
+ db.getUserById(id).then((u) => (u ? (req.user = u, next()) : res.status(401).json({ error: 'Authentication required' }))).catch(next);
+ };
+ const express = require('express');
+ const http = require('http');
+ const routes = require('../server/elements/routes');
+ const app = express();
+ app.use(express.json());
+ app.use('/api/me/elements', routes.ownerRouter);
+ app.use('/api/channels', routes.channelRouter);
+ const server = http.createServer(app);
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ const call = async (method, path, body, user) => {
+ const res = await fetch(`http://127.0.0.1:${server.address().port}${path}`, {
+ method, headers: { 'content-type': 'application/json', ...(user ? { 'x-test-user': String(user) } : {}) },
+ body: body === undefined ? undefined : JSON.stringify(body),
+ });
+ return { status: res.status, json: await res.json().catch(() => null), headers: res.headers };
+ };
+ await store.setEnabled(2, true);
+ const deckRead = await call('GET', '/api/channels/ann/elements');
+ assert.equal(deckRead.status, 200);
+ assert.equal(deckRead.json.surface, 'live.deck');
+ assert.equal(deckRead.json.enabled, true);
+ assert.equal(deckRead.json.live, true);
+ assert.equal(deckRead.json.viewer.signed_in, false);
+ assert.ok(deckRead.json.elements.length > 0);
+ assert.equal(deckRead.json.elements[0].can_press, false);
+ assert.equal(deckRead.json.elements[0].reason, 'sign_in');
+
+ const library = await call('GET', '/api/me/elements', undefined, 2);
+ assert.equal(library.status, 200);
+ assert.equal(library.json.enabled, true);
+ assert.ok(Array.isArray(library.json.elements) && library.json.elements.length > 0);
+
+ const made = await call('POST', '/api/me/elements', {
+ name: 'API button', look: look('API button'), recipe: recipe(), capabilities: ['screen.counter'], initial_state: { dances: 0 },
+ }, 2);
+ assert.equal(made.status, 201, JSON.stringify(made.json));
+ assert.match(made.json.element.id, /^el_/);
+ const patched = await call('PATCH', `/api/me/elements/${made.json.element.id}`, { name: 'API button 2' }, 2);
+ assert.equal(patched.status, 200);
+ assert.equal(patched.json.element.name, 'API button 2');
+ const bad = await call('POST', '/api/me/elements', {
+ name: 'Undeclared', look: look(), recipe: recipe(), capabilities: ['screen.alert'],
+ }, 2);
+ assert.equal(bad.status, 400);
+ assert.ok(Array.isArray(bad.json.errors) && bad.json.errors.length > 0);
+
+ const pressed = await call('POST', `/api/channels/ann/elements/${made.json.element.id}/press`, {}, 3);
+ assert.equal(pressed.status, 200, JSON.stringify(pressed.json));
+ assert.equal(pressed.json.ok, true);
+ assert.deepEqual(pressed.json.state, { revision: 1, values: { dances: 1 } });
+ assert.equal(typeof pressed.json.balance, 'number');
+ const deleted = await call('DELETE', `/api/me/elements/${made.json.element.id}`, undefined, 2);
+ assert.equal(deleted.status, 200);
+ assert.equal((await call('POST', `/api/channels/ann/elements/${made.json.element.id}/press`, {}, 3)).status, 404);
+
+ const settingsOff = await call('PUT', '/api/me/elements/settings', { enabled: false }, 2);
+ assert.equal(settingsOff.status, 200);
+ assert.deepEqual(settingsOff.json, { enabled: false });
+ const hidden = await call('GET', '/api/channels/ann/elements');
+ assert.equal(hidden.json.enabled, false);
+ assert.deepEqual(hidden.json.elements, []);
+ await store.setEnabled(2, true);
+ server.close();
+ }
+
+ quiet('elements: all checks passed');
+ process.exit(0);
+})().catch((err) => { quiet(err); process.exit(1); });