openvibers/openvibe.live Commits

Live's old robot controls tables are dropped (plan T15 B9): control configs, buttons, whitelist, per-stream controls, ONVIF cameras and api_keys, with the channel, slot and stream columns that pointed at them (#292)

OP OpenVibers committed 23 days ago · 42439e9266 · 7 files, +91 −23

Robots are OpenVibe.Bot's: the configs became Bot robots on 2026-10-07 and Live#119 (deployed 10-14) deleted the
code that read them. Migration 0011 is a contract after 0002, so it runs at the next boot. The one-release
public/js/controls.js stub goes with it, and the API docs stop listing control_config_id.

Changes

ddiff --git a/AGENTS.md b/AGENTS.md
iindex 393a8929..55cef05f 100644
---- a/AGENTS.md
++++ b/AGENTS.md
@@@ -63,7 +63,7 @@ Each feature lives in its own `server/<feature>/` directory with `routes.js` + s
- **DM delivery:** Server verifies `dm.isParticipant()` before delivering — always maintain this check.
- **Schema:** every table is in `migrations/`; `ensureTables()` is gone. The frozen and Chat-owned tables are not in the PostgreSQL schema at all.
--**Robots:** OpenVibe.Bot owns them (plan T15). Live has no control socket, hardware API or ONVIF code: a channel bound to a robot (`channels.bot_robot_id`, `PUT /api/streams/channel/:username/bot`) embeds the robot's panel (`server/bot/embed.js`), and chat's `!forward`/`!say` … reach Live at `POST /internal/chat-effects/hardware`, which forwards them to Bot's `POST /api/v1/robots/:id/commands` as the viewer (`server/bot/bot-client.js`, Network grant `bot.robot.control`). The old controls tables stay unread until their contract migration drops them.
++**Robots:** OpenVibe.Bot owns them (plan T15). Live has no control socket, hardware API or ONVIF code: a channel bound to a robot (`channels.bot_robot_id`, `PUT /api/streams/channel/:username/bot`) embeds the robot's panel (`server/bot/embed.js`), and chat's `!forward`/`!say` … reach Live at `POST /internal/chat-effects/hardware`, which forwards them to Bot's `POST /api/v1/robots/:id/commands` as the viewer (`server/bot/bot-client.js`, Network grant `bot.robot.control`). Live's old controls tables (`control_configs`, its buttons and whitelist, `stream_controls`, the ONVIF camera tables, `api_keys`) and the columns that pointed at them are dropped by migration `0011_drop_live_controls` (`test/controls-tables-gone.test.js`).
## WebSocket Endpoints
ddiff --git a/migrations/0011_drop_live_controls.sql b/migrations/0011_drop_live_controls.sql
nnew file mode 100644
iindex 00000000..663d9119
---- /dev/null
++++ b/migrations/0011_drop_live_controls.sql
@@@ -0,0 +1,31 @@
++-- phase: contract
++-- after: 0002
++-- Plan T15 B9: robots are OpenVibe.Bot's. Live's control configs became Bot robots on 2026-10-07 (Bot's
++-- live_conversions records configs 8, 9 and 14; config 13 had no buttons, so there was nothing to convert), and
++-- Live#119 (deployed 2026-10-14) deleted everything that read these: server/controls (the control socket, its
++-- stream-key and api_keys auth, the button and whitelist routes), the ONVIF camera routes and client. Since then
++-- nothing in Live, and no other service, reads them (test/controls-tables-gone.test.js); a channel bound to a robot
++-- embeds Bot's panel (channels.bot_robot_id). The N-1 release names none of them.
++-- Production on 2026-10-21: 4 configs, 25 buttons, 1 whitelist row, 11,806 per-stream copies of buttons, no cameras,
++-- no presets and no api_keys. Rollback: the robots are in ov_bot; the operator-side dump taken before the deploy
++-- restores the tables and columns if ever needed.
++-- streams is busy: wait at most 5 s for its lock rather than queue every reader behind the ALTER (a timeout fails
++-- the boot, and the deploy keeps the running release).
++SET LOCAL lock_timeout = '5s';
++
++ALTER TABLE channels DROP COLUMN IF EXISTS control_mode;
++ALTER TABLE channels DROP COLUMN IF EXISTS anon_controls_enabled;
++ALTER TABLE channels DROP COLUMN IF EXISTS control_rate_limit_ms;
++ALTER TABLE channels DROP COLUMN IF EXISTS active_control_config_id;
++ALTER TABLE channels DROP COLUMN IF EXISTS video_click_enabled;
++ALTER TABLE channels DROP COLUMN IF EXISTS video_click_rate_limit_ms;
++ALTER TABLE managed_streams DROP COLUMN IF EXISTS control_config_id;
++ALTER TABLE streams DROP COLUMN IF EXISTS control_config_id;
++
++DROP TABLE IF EXISTS stream_controls;
++DROP TABLE IF EXISTS camera_presets;
++DROP TABLE IF EXISTS camera_profiles;
++DROP TABLE IF EXISTS control_config_buttons;
++DROP TABLE IF EXISTS control_whitelist;
++DROP TABLE IF EXISTS control_configs;
++DROP TABLE IF EXISTS api_keys;
ddiff --git a/public/fragments/documentation.html b/public/fragments/documentation.html
iindex 7dd5834e..fd1f0432 100644
---- a/public/fragments/documentation.html
++++ b/public/fragments/documentation.html
@@@ -225,19 +225,19 @@ console.log('Acting as:', me.user.username);
<thead><tr><th>Method</th><th>Path</th><th>Auth</th><th>Notes</th></tr></thead>
<tbody>
<tr><td>GET</td><td>/api/streams/managed</td><td>Yes</td><td>My slots + slot limit</td></tr>
--<tr><td>POST</td><td>/api/streams/managed</td><td>Yes</td><td>Create a slot &rarr; unique <code>stream_key</code>. Body: <code>title, description, category, protocol, is_nsfw, slug, streaming_method, control_config_id</code></td></tr>
++<tr><td>POST</td><td>/api/streams/managed</td><td>Yes</td><td>Create a slot &rarr; unique <code>stream_key</code>. Body: <code>title, description, category, protocol, is_nsfw, slug, streaming_method</code></td></tr>
<tr><td>PUT</td><td>/api/streams/managed/:id</td><td>Owner</td><td>Update slot + slot settings (see below)</td></tr>
<tr><td>DELETE</td><td>/api/streams/managed/:id</td><td>Owner</td><td>Delete slot (409 if live)</td></tr>
<tr><td>POST</td><td>/api/streams/managed/:id/regenerate-key</td><td>Owner</td><td>New slot stream key</td></tr>
</tbody>
</table>
--<p><strong>Per-slot settings</strong> (PUT <code>/managed/:id</code>): <code>streaming_method</code> (<code>browser|whip|cli|rtmp</code> &mdash; auto-derives <code>protocol</code>), <code>browser_mode</code> (<code>camera|mic_only|screen</code>), <code>default_vod_visibility</code> / <code>default_clip_visibility</code> (<code>public|unlisted|private</code>), <code>slot_vod_recording_enabled</code>, <code>slot_clip_recording_enabled</code>, <code>slot_clip_notify_enabled</code> (0/1), weather fields, <code>control_config_id</code>.</p>
++<p><strong>Per-slot settings</strong> (PUT <code>/managed/:id</code>): <code>streaming_method</code> (<code>browser|whip|cli|rtmp</code> &mdash; auto-derives <code>protocol</code>), <code>browser_mode</code> (<code>camera|mic_only|screen</code>), <code>default_vod_visibility</code> / <code>default_clip_visibility</code> (<code>public|unlisted|private</code>), <code>slot_vod_recording_enabled</code>, <code>slot_clip_recording_enabled</code>, <code>slot_clip_notify_enabled</code> (0/1), weather fields.</p>
<h3>Go live / sessions</h3>
<table class="doc-table" tabindex="0">
<thead><tr><th>Method</th><th>Path</th><th>Auth</th><th>Notes</th></tr></thead>
<tbody>
--<tr><td>POST</td><td>/api/streams</td><td>Yes</td><td><strong>Go live</strong> from a slot. Body: <code>managed_stream_id</code> (required) + optional <code>title, category, tags, is_nsfw, control_config_id</code> &rarr; <code>{ stream, endpoint }</code></td></tr>
++<tr><td>POST</td><td>/api/streams</td><td>Yes</td><td><strong>Go live</strong> from a slot. Body: <code>managed_stream_id</code> (required) + optional <code>title, category, tags, is_nsfw</code> &rarr; <code>{ stream, endpoint }</code></td></tr>
<tr><td>GET</td><td>/api/streams/:id/endpoint</td><td>Owner</td><td>Ingest endpoint + <code>stream_key</code> for the session's protocol</td></tr>
<tr><td>PUT</td><td>/api/streams/:id</td><td>Owner</td><td>Update live session (title/description/category/tags)</td></tr>
<tr><td>DELETE</td><td>/api/streams/:id</td><td>Owner</td><td>End the session</td></tr>
ddiff --git a/public/js/controls.js b/public/js/controls.js
ddeleted file mode 100644
iindex 0877aae1..00000000
---- a/public/js/controls.js
++++ /dev/null
@@@ -1,11 +0,0 @@
--'use strict';
--/* Live's robot controls are gone (plan T15: robots are OpenVibe.Bot's; a bound channel shows the robot's panel and chat
-- drives it through Bot). This file stays for one release so a tab still running the previous client, whose feature
-- loader asks for /js/controls.js, gets its functions as no-ops instead of a failed load. It goes with the controls
-- tables' contract migration. */
--(() => {
-- const noop = () => {};
-- for (const name of ['loadStreamControls', 'destroyControlWs', 'toggleControlsSection', 'toggleControlsDock']) {
-- if (typeof window[name] !== 'function') window[name] = noop;
-- }
--})();
ddiff --git a/test/controls-tables-gone.test.js b/test/controls-tables-gone.test.js
nnew file mode 100644
iindex 00000000..a5671932
---- /dev/null
++++ b/test/controls-tables-gone.test.js
@@@ -0,0 +1,53 @@
++'use strict';
++
++// Plan T15 B9: robots are OpenVibe.Bot's. Live's control configs became Bot robots on 2026-10-07 and Live#119 deleted
++// the code that read them (server/controls, the control socket and its keys, the ONVIF routes). Nothing under server/,
++// scripts/ or public/js reads Live's old controls tables or the channel/slot/stream columns that pointed at them, so
++// the contract migration that drops them breaks nothing.
++
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++
++const ROOT = path.join(__dirname, '..');
++const TABLES = ['api_keys', 'camera_presets', 'camera_profiles', 'control_config_buttons', 'control_configs', 'control_whitelist', 'stream_controls'];
++const COLUMNS = {
++ channels: ['active_control_config_id', 'anon_controls_enabled', 'control_mode', 'control_rate_limit_ms', 'video_click_enabled', 'video_click_rate_limit_ms'],
++ managed_streams: ['control_config_id'],
++ streams: ['control_config_id'],
++};
++const NAMES = new RegExp(`\\b(${[...TABLES, ...new Set(Object.values(COLUMNS).flat())].join('|')})\\b`);
++const isCommentLine = (line) => /^\s*(\/\/|\*|\/\*|--)/.test(line);
++
++function walk(dir, out = []) {
++ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
++ const p = path.join(dir, e.name);
++ if (e.isDirectory()) { if (e.name !== 'node_modules') walk(p, out); } else if (e.name.endsWith('.js')) out.push(p);
++ }
++ return out;
++}
++
++const offenders = [];
++for (const file of ['server', 'scripts', 'public/js'].flatMap((d) => walk(path.join(ROOT, d)))) {
++ fs.readFileSync(file, 'utf8').split('\n').forEach((line, i) => {
++ if (!isCommentLine(line) && NAMES.test(line)) offenders.push(`${path.relative(ROOT, file)}:${i + 1}: ${line.trim().slice(0, 120)}`);
++ });
++}
++assert.deepStrictEqual(offenders, [], 'robots are OpenVibe.Bot\'s (plan T15): a bound channel embeds its robot (server/bot/)');
++// The one-release stub for tabs still running the client that loaded controls.js leaves with the tables.
++assert.ok(!fs.existsSync(path.join(ROOT, 'public/js/controls.js')), 'public/js/controls.js is gone');
++assert.ok(!fs.existsSync(path.join(ROOT, 'server/controls')), 'server/controls is gone');
++
++// The contract migration drops them, after the N-1 window (it names 0002, so the migrator holds it for 7 days).
++const drop = fs.readFileSync(path.join(ROOT, 'migrations', '0011_drop_live_controls.sql'), 'utf8');
++assert.match(drop, /^-- phase: contract$/m);
++assert.match(drop, /^-- after: 0002$/m);
++const dropped = [...drop.matchAll(/^DROP TABLE IF EXISTS (\w+);$/gm)].map((m) => m[1]).sort();
++assert.deepStrictEqual(dropped, TABLES, '0011 drops exactly the seven tables');
++const columns = {};
++for (const [, table, column] of drop.matchAll(/^ALTER TABLE (\w+) DROP COLUMN IF EXISTS (\w+);$/gm)) (columns[table] ||= []).push(column);
++for (const t of Object.keys(columns)) columns[t].sort();
++assert.deepStrictEqual(columns, COLUMNS, '0011 drops exactly the columns that pointed at them');
++// Every column goes before the tables, so no foreign key still names control_configs when it is dropped.
++assert.ok(drop.lastIndexOf('DROP COLUMN') < drop.indexOf('DROP TABLE'), 'columns first, then tables');
++console.log('controls tables: nothing under server/, scripts/ or public/js names them, and 0011 drops them');
ddiff --git a/test/identity-columns.test.js b/test/identity-columns.test.js
iindex 1e88f4bb..3d03a75f 100644
---- a/test/identity-columns.test.js
++++ b/test/identity-columns.test.js
@@@ -17,7 +17,6 @@ const db = require('../server/db/database');
const walk = (dir) => { for (const e of fs.readdirSync(dir, { withFileTypes: true })) { const p = path.join(dir, e.name); if (e.isDirectory()) { if (e.name !== 'node_modules') walk(p); } else if (p.endsWith('.js')) files.push(p); } };
walk(path.join(__dirname, '..', 'server'));
// Reads of the legacy columns: a users alias or a user object's field, or SQL selecting or filtering them on users.
-- // camera_profiles.password_hash (ONVIF device credentials, server/controls) is another table: `camera.password_hash`.
const READS = [
/\bu\.(email|password_hash)\b/,
/\b\w*[uU]ser\.(email|password_hash)\b/,
ddiff --git a/test/security-stream-keys.test.js b/test/security-stream-keys.test.js
iindex 20250c8f..e4dde62c 100644
---- a/test/security-stream-keys.test.js
++++ b/test/security-stream-keys.test.js
@@@ -8,7 +8,7 @@
* over RTMP or WHIP. Those tests pin the endpoints that leaked. This one covers the class: it seeds
* a streamer whose every credential is a sentinel (account key, a Live slot key, an OpenRestream slot's
* rotated Live key, a restream key and SRT passphrase, RobotStreamer / platform / PowerChat tokens,
-- * AI keys, control key and API token hashes, a camera password hash), boots the real server as a
++ * AI keys and an API token hash), boots the real server as a
* restore-drill instance (test/security-crawl.js), and requests EVERY GET route Express knows,
* plus the SPA pages, as five other people: anonymous, a viewer, another streamer, a global mod and
* an admin. No response body or header, and no log line written while serving them, may contain
@@@ -41,9 +41,7 @@ const K = {
powerchatRefresh: 'powerchat-sentinel-refresh-d8d8',
aiBotToken: 'aibot-sentinel-token-c9c9',
byoKey: 'sentinel-not-a-secret-byo',
-- controlKeyHash: 'e1'.repeat(32),
apiTokenHash: 'f2'.repeat(32),
-- cameraPasswordHash: 'camera-sentinel-hash-a3a3',
// The other people's own account keys: none of them may see another's, and the crawl must not
// echo a caller's own key on a page about someone else either.
fan: 'd4'.repeat(16),
@@@ -83,9 +81,7 @@ const SEED = `(async () => {
await ins('INSERT INTO powerchat_connections (user_id, powerchat_username, powerchat_user_id, access_token, refresh_token) VALUES (?, ?, ?, ?, ?)', [owner, 'keyowner', 'p1', K.powerchatAccess, K.powerchatRefresh]);
await ins('INSERT INTO ai_chatbot_configs (user_id, enabled, api_token) VALUES (?, 1, ?)', [owner, K.aiBotToken]);
await ins('INSERT INTO channel_ai_config (user_id, enabled, use_shared_key, byo_key) VALUES (?, 1, 0, ?)', [owner, K.byoKey]);
-- await ins('INSERT INTO api_keys (user_id, key_hash, label) VALUES (?, ?, ?)', [owner, K.controlKeyHash, 'Robot']);
await ins('INSERT INTO api_tokens (user_id, token_hash, label, scopes) VALUES (?, ?, ?, ?)', [owner, K.apiTokenHash, 'Bot', '["chat","read"]']);
-- const cam = Number((await ins('INSERT INTO camera_profiles (user_id, stream_id, name, onvif_url, username, password_hash) VALUES (?, ?, ?, ?, ?, ?) RETURNING id', [owner, live, 'Desk cam', 'http://camera.example.test', 'admin', K.cameraPasswordHash])).lastInsertRowid);
await db.run('INSERT INTO follows (follower_id, streamer_id) VALUES (?, ?)', [fan, owner]);
// What Live sends out about the stream, built from these rows the way production builds it.
@@@ -102,7 +98,7 @@ const SEED = `(async () => {
await le.announceGoLive(raw, streamer);
out.sse = writes.join('');
out.rawHadKey = raw.managed_stream_key === K.slotMain && streamer.stream_key === K.account;
-- return { ids: { owner, fan, peer, mod, admin, channel: ch.id, slot, slot2, live, ended, cam }, out };
++ return { ids: { owner, fan, peer, mod, admin, channel: ch.id, slot, slot2, live, ended }, out };
})()`;
function hits(text, needles) {
@@@ -165,7 +161,7 @@ function hits(text, needles) {
});
// Parameter values: every seeded id and name, so each route is asked about the owner's things.
-- const numeric = [ids.live, ids.slot, ids.owner, ids.channel, ids.ended, ids.slot2, ids.cam, 1];
++ const numeric = [ids.live, ids.slot, ids.owner, ids.channel, ids.ended, ids.slot2, 1];
const byName = {
username: ['keyowner'], user: ['keyowner'], userId: [ids.owner], channelId: [ids.channel],
streamId: [ids.live, ids.ended], managedStreamId: [ids.slot, ids.slot2], slotIdOrSlug: ['main', ids.slot, 'relay'],