openvibers/openvibe.live Commits

Live T3 N+2: Live's copies of seven chat tables are dropped, and robot commands from chat pass the control gate.

OP OpenVibers committed 29 Sept 2026 · fb3957f3ca · 12 files, +197 −295

Migration 005_drop_chat_staged_tables drops channel_moderators, channel_moderation_settings, user_tags, chat_ai_summaries, chat_timeline_events, chat_staged_outbox and chat_dual_read_stats (OpenVibe.Chat owns the data since 2026-09-29 14:46 UTC; N-1 8654ef6 no longer reads them; backups kept operator-side) with their schema, rebuild code and tests. emotes stays one more release because N-1 still counts it, but nothing reads it any more: the home stats drop the emotes chip.

Security: chat's !forward/!backward/!left/!right/!liftup/!liftdown/!headup/!headdown and !say reached a streamer's robot with no check at all. The hardware effect now applies the control panel's rules (control mode disabled, anonymous controls off, whitelist; the owner always may; a missing viewer id counts as anonymous) and a 250 ms per-viewer cooldown (plan T15 research). npm test 154/155 (1 skipped: no local model).

Changes

ddiff --git a/AGENTS.md b/AGENTS.md
iindex b81e53eb..36830277 100644
---- a/AGENTS.md
++++ b/AGENTS.md
@@@ -4,7 +4,7 @@
Self-hosted live streaming platform — Node.js/Express monolith, vanilla JS SPA frontend, SQLite (better-sqlite3). Part of the **OpenVibe network** with SSO via openvibe.network. See [README.md](README.md) for features and [docs/architecture.md](docs/architecture.md) for system design.
--**Network context:** SSO/OAuth2 + the OpenCoins wallet come from **OpenVibe.Network** (`server/monetization/wallet-client.js`). The media subsystem (VODs/clips/pastes/thumbnails/files) lives in **OpenVibe.Media** — Live talks to it via [server/media-client.js](server/media-client.js), the thin proxy routers in [server/media-proxy/](server/media-proxy/), and the Media-backed recorder [server/streaming/recorder.js](server/streaming/recorder.js). Media completion events arrive over OpenVibe.Events at `POST /internal/media-events` (`MEDIA_EVENTS_AUTHORITY`; the older `POST /internal/media-webhook` stays during the transition). The legacy local `vods`/`clips`/`pastes`/`paste_likes`/`paste_comments` tables are FROZEN: never read or write them (`test/frozen-tables.test.js` enforces it; ask Media or Community through `server/media-proxy/lookups.js`, `media-client.js`, `pastes-client.js`); VOD/clip comments are OpenVibe.Community threads ([server/comments-client.js](server/comments-client.js)) and the local `comments` table is READ-ONLY too; Chat owns `channel_moderators`, `channel_moderation_settings`, `emotes`, `user_tags`, `chat_ai_summaries` and `chat_timeline_events`; Live reads them only through `server/chat/moderation-client.js` (Chat's internal read API, capability `chat.moderation.read`, cached 30 s) and the chat-AI summaries through `server/chat/insight-client.js` (Chat's public `/api/chat/ai/*`, cached 60 s), and keeps no copy; the six tables stay one more release for N-1 (their write relay/dual read and Live's chat-AI job were deleted; `test/chat-staged-tables.test.js` guards that nothing else reads or writes them); Live-owned AI/transcript state for Media-hosted content lives in `vod_ai_state`/`clip_ai_state`. See ../CONTRACTS.md for the binding inter-service contracts. **Money:** `BILLING_AUTHORITY` (read only in [server/monetization/money-authority.js](server/monetization/money-authority.js)) is `live` by default; `billing` makes OpenVibe.Billing the ledger ([billing-client.js](server/monetization/billing-client.js), [billing-actions.js](server/monetization/billing-actions.js)) and Live's `openvibe_bucks_*`/`transactions`/`payment_orders`/`subscriptions` read-only (a database-level tripwire throws). The owner-only `money_writes_frozen` flag (`/api/admin/money`) refuses money actions in both modes.
++**Network context:** SSO/OAuth2 + the OpenCoins wallet come from **OpenVibe.Network** (`server/monetization/wallet-client.js`). The media subsystem (VODs/clips/pastes/thumbnails/files) lives in **OpenVibe.Media** — Live talks to it via [server/media-client.js](server/media-client.js), the thin proxy routers in [server/media-proxy/](server/media-proxy/), and the Media-backed recorder [server/streaming/recorder.js](server/streaming/recorder.js). Media completion events arrive over OpenVibe.Events at `POST /internal/media-events` (`MEDIA_EVENTS_AUTHORITY`; the older `POST /internal/media-webhook` stays during the transition). The legacy local `vods`/`clips`/`pastes`/`paste_likes`/`paste_comments` tables are FROZEN: never read or write them (`test/frozen-tables.test.js` enforces it; ask Media or Community through `server/media-proxy/lookups.js`, `media-client.js`, `pastes-client.js`); VOD/clip comments are OpenVibe.Community threads ([server/comments-client.js](server/comments-client.js)) and the local `comments` table is READ-ONLY too; Chat owns `channel_moderators`, `channel_moderation_settings`, `emotes`, `user_tags`, `chat_ai_summaries` and `chat_timeline_events`; Live reads them only through `server/chat/moderation-client.js` (Chat's internal read API, capability `chat.moderation.read`, cached 30 s) and the chat-AI summaries through `server/chat/insight-client.js` (Chat's public `/api/chat/ai/*`, cached 60 s), and keeps no copy of them (`test/chat-staged-tables.test.js` guards that nothing under `server/` reads or writes them; Live's `emotes` copy is unread since N+2 and dropped next); Live-owned AI/transcript state for Media-hosted content lives in `vod_ai_state`/`clip_ai_state`. See ../CONTRACTS.md for the binding inter-service contracts. **Money:** `BILLING_AUTHORITY` (read only in [server/monetization/money-authority.js](server/monetization/money-authority.js)) is `live` by default; `billing` makes OpenVibe.Billing the ledger ([billing-client.js](server/monetization/billing-client.js), [billing-actions.js](server/monetization/billing-actions.js)) and Live's `openvibe_bucks_*`/`transactions`/`payment_orders`/`subscriptions` read-only (a database-level tripwire throws). The owner-only `money_writes_frozen` flag (`/api/admin/money`) refuses money actions in both modes.
## Commands
ddiff --git a/docs/chat-system.md b/docs/chat-system.md
iindex 742bc939..1512ef54 100644
---- a/docs/chat-system.md
++++ b/docs/chat-system.md
@@@ -38,8 +38,8 @@ with the same WebSocket protocol and REST paths. `CHAT_AUTHORITY=chat` switches
`/api/emotes`, `/api/chat/ai/…`); alert sounds are Chat's, played on Live's request through the bridge op
`playAlertSound [streamerId, streamId, kind]` ([server/monetization/alerts.js](../server/monetization/alerts.js)).
Live's staged-table machinery (the write relay, dual read and handoff in `chat-tables*.js`), Live's
-- chat-AI summary job and the old emote/channel-moderation routes were deleted in the N+1 release; the
-- six tables (and `chat_staged_outbox`/`chat_dual_read_stats`) stay one release for N-1 (ADR-016).
++ chat-AI summary job and the old emote/channel-moderation routes were deleted in the N+1 release; Live's
++ copies of the seven supporting tables were dropped in N+2, and `emotes` is unread and dropped next (ADR-016).
## Features
ddiff --git a/public/js/app-home.js b/public/js/app-home.js
iindex f2d4c8dd..684fd6d5 100644
---- a/public/js/app-home.js
++++ b/public/js/app-home.js
@@@ -334,7 +334,12 @@ function _renderHeroStats(stats) {
{ icon: 'fa-scissors', num: stats.clips, label: 'Clips', metric: 'clips', title: 'Clips created', recent: R.clips },
{ icon: 'fa-clock', num: stats.streamHours, label: 'Hours', metric: 'hours', title: 'Hours of video archived', recent: R.hours, unit: 'h' },
{ icon: 'fa-brain', num: stats.aiMemories, label: 'AI Moments', metric: 'aiMoments', title: 'Moments the AI remembers across every stream', recent: R.aiMoments },
-- { recent: R.emotes, icon: 'fa-face-grin-squint', num: stats.emotes, label: 'Emotes', metric: 'emotes', title: 'Custom channel emotes uploaded' },
++ // The emotes series is gone (OpenVibe.Chat owns the table; Live stopped reading it in T3
++ // N+2), so the chip and its chart are skipped — `stats.emotes` is null, the same "no series"
++ // signal the OpenCoins chips use. No other markup changes.
++ ...(stats.emotes != null ? [
++ { recent: R.emotes, icon: 'fa-face-grin-squint', num: stats.emotes, label: 'Emotes', metric: 'emotes', title: 'Custom channel emotes uploaded' },
++ ] : []),
{ icon: 'fa-paste', num: stats.pastes, label: 'Pastes', metric: 'pastes', title: `${stats.pasteText || 0} text · ${stats.pasteImages || 0} image pastes`, sub: (stats.pasteText != null && stats.pasteImages != null) ? `${_fmtCount(stats.pasteText)} txt · ${_fmtCount(stats.pasteImages)} img` : '' },
],
});
ddiff --git a/server/chat/live-context-routes.js b/server/chat/live-context-routes.js
iindex 379b2d3f..271cfed5 100644
---- a/server/chat/live-context-routes.js
++++ b/server/chat/live-context-routes.js
@@@ -493,13 +493,37 @@ effectsRouter.post('/media-queue', async (req, res) => {
}
});
--// Cozmo / !say: the robot's control socket is in Live, keyed by the streamer's stream key.
++// Cozmo / !say: the robot's control socket is in Live, keyed by the streamer's stream key. Chat commands pass the
++// same gate as the control panel (control-server.js validateControlPermission): the channel's control mode, its
++// anonymous-control switch and its whitelist, judged on the viewer Chat names (`from_user_id`; none = anonymous),
++// plus a per-viewer cooldown so a chat flood cannot drive a robot faster than a held key.
const HARDWARE_COMMANDS = new Set(['forward', 'backward', 'turn_left', 'turn_right', 'lift_up', 'lift_down', 'head_up', 'head_down']);
++const HARDWARE_COOLDOWN_MS = 250;
++const hardwareLast = new Map(); // `${streamer}:${viewer}` → last command ms
++function hardwareRefusal(owner, actorId) {
++ const channel = db.getChannelByUserId(owner.id);
++ if (!channel) return null;
++ const mode = channel.control_mode || 'open';
++ if (mode === 'disabled') return 'controls_disabled';
++ if (!actorId && (!channel.anon_controls_enabled || mode === 'whitelist')) return 'login_required';
++ if (mode === 'whitelist' && actorId !== owner.id
++ && !db.get('SELECT 1 FROM control_whitelist WHERE channel_id = ? AND user_id = ?', [channel.id, actorId])) return 'not_whitelisted';
++ return null;
++}
effectsRouter.post('/hardware', (req, res) => {
const command = String(req.body?.command || '');
if (!HARDWARE_COMMANDS.has(command) && !/^say:[\s\S]{1,200}$/.test(command)) return fail(res, 400, 'unknown command');
const user = db.getUserById(int(req.body.streamer_user_id));
if (!user) return res.json({ ok: false, reason: 'no_user' });
++ const actorId = int(req.body.from_user_id) || null;
++ const refused = hardwareRefusal(user, actorId);
++ if (refused) return res.json({ ok: false, reason: refused });
++ const who = actorId ? `u${actorId}` : `a:${String(req.body.from_anon || req.body.from_user || '').slice(0, 64)}`;
++ const key = `${user.id}:${who}`;
++ const now = Date.now();
++ if (now - (hardwareLast.get(key) || 0) < HARDWARE_COOLDOWN_MS) return res.json({ ok: false, reason: 'cooldown' });
++ hardwareLast.set(key, now);
++ if (hardwareLast.size > 5000) hardwareLast.delete(hardwareLast.keys().next().value);
const controlServer = require('../controls/control-server');
const hwWs = controlServer.hardwareClients.get(user.stream_key);
if (!hwWs || hwWs.readyState !== 1) return res.json({ ok: false, reason: 'no_hardware' });
ddiff --git a/server/chat/tags.js b/server/chat/tags.js
iindex b63d6adc..cdfed10d 100644
---- a/server/chat/tags.js
++++ b/server/chat/tags.js
@@@ -49,26 +49,16 @@ const TAGS = {
function ensureTagTables() {
db.getDb().exec(`
-- CREATE TABLE IF NOT EXISTS user_tags (
-- id INTEGER PRIMARY KEY AUTOINCREMENT,
-- user_id INTEGER NOT NULL,
-- tag_id TEXT NOT NULL,
-- source TEXT DEFAULT 'shop',
-- granted_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-- UNIQUE(user_id, tag_id),
-- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
-- );
CREATE TABLE IF NOT EXISTS user_equipped_tag (
user_id INTEGER NOT NULL PRIMARY KEY,
tag_id TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
`);
-- try { db.getDb().exec('CREATE INDEX IF NOT EXISTS idx_user_tags_user ON user_tags(user_id)'); } catch { /* */ }
}
--// getUserTags (the only reader of `user_tags`) went with roadmap T3: OpenVibe.Chat owns that table,
--// which had no production caller in Live. The equipped tag (user_equipped_tag) is not staged and stays.
++// `user_tags` went with roadmap T3: OpenVibe.Chat owns it (Live's copy was dropped in N+2) and no
++// production caller in Live ever read it here. The equipped tag (user_equipped_tag) is not staged and stays.
/** The user's equipped tag, or null. */
function getEquippedTag(userId) {
ddiff --git a/server/db/database.js b/server/db/database.js
iindex eeb2e553..a77cb8f2 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -1325,42 +1325,6 @@ function initDb() {
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`);
-- // Rolling AI summaries of chat activity — for the global chat overview/timeline
-- // and per-user "today vs all-time" insights. scope='global' uses subject_id=0.
-- // window: 'rolling' (canonical processing state + condensed memory + timeline),
-- // 'recent'/'24h'/'alltime' (rendered overviews). One incremental LLM call folds
-- // new messages into memory + refreshes overviews, so cost stays flat with volume.
-- database.exec(`CREATE TABLE IF NOT EXISTS chat_ai_summaries (
-- id INTEGER PRIMARY KEY AUTOINCREMENT,
-- scope TEXT NOT NULL,
-- subject_id INTEGER NOT NULL DEFAULT 0,
-- window TEXT NOT NULL,
-- overview TEXT DEFAULT '',
-- memory_json TEXT DEFAULT '',
-- timeline_json TEXT DEFAULT '[]',
-- message_count INTEGER DEFAULT 0,
-- window_message_count INTEGER DEFAULT 0,
-- last_message_id INTEGER DEFAULT 0,
-- window_label TEXT DEFAULT '',
-- window_start DATETIME,
-- window_end DATETIME,
-- updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-- UNIQUE(scope, subject_id, window)
-- )`);
-- database.exec(`CREATE INDEX IF NOT EXISTS idx_chat_ai_scope ON chat_ai_summaries(scope, subject_id, window)`);
-- // Growing log of AI timeline "notable moments" (beyond the 40 kept in the summary JSON),
-- // so the global timeline can be browsed/searched/paginated with real history.
-- database.exec(`CREATE TABLE IF NOT EXISTS chat_timeline_events (
-- id INTEGER PRIMARY KEY AUTOINCREMENT,
-- scope TEXT NOT NULL DEFAULT 'global',
-- subject_id INTEGER NOT NULL DEFAULT 0,
-- ts DATETIME NOT NULL,
-- label TEXT NOT NULL,
-- detail TEXT DEFAULT '',
-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP
-- )`);
-- database.exec(`CREATE INDEX IF NOT EXISTS idx_chat_tl_scope_ts ON chat_timeline_events(scope, subject_id, ts DESC)`);
-- database.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_chat_tl_dedup ON chat_timeline_events(scope, subject_id, ts, label)`);
// Daily easter-egg solves (one per solver per day).
database.exec(`CREATE TABLE IF NOT EXISTS easter_egg_solves (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@@ -1539,16 +1503,6 @@ function initDb() {
if (!cols.includes('sort_order')) database.exec('ALTER TABLE donation_goals ADD COLUMN sort_order INTEGER DEFAULT 0');
} catch (e) { console.warn('[DB] donation_goals media migration:', e.message); }
-- // Streamer alert sounds (per channel): a sound played on every donation, plus an
-- // optional override that plays when a donation goal is reached.
-- try {
-- const cols = database.prepare('PRAGMA table_info(channel_moderation_settings)').all().map(c => c.name);
-- if (!cols.includes('donation_sound_url')) database.exec('ALTER TABLE channel_moderation_settings ADD COLUMN donation_sound_url TEXT');
-- if (!cols.includes('donation_sound_mime')) database.exec('ALTER TABLE channel_moderation_settings ADD COLUMN donation_sound_mime TEXT');
-- if (!cols.includes('goal_sound_url')) database.exec('ALTER TABLE channel_moderation_settings ADD COLUMN goal_sound_url TEXT');
-- if (!cols.includes('goal_sound_mime')) database.exec('ALTER TABLE channel_moderation_settings ADD COLUMN goal_sound_mime TEXT');
-- } catch (e) { console.warn('[DB] alert-sound settings migration:', e.message); }
--
// chat_messages.metadata — JSON sidecar for rich/animated events (donations + goal
// reached) so they persist in history WITH their structured payload (image, amount,
// goal title, animation kind) while `message` stays human-readable.
@@@ -1613,79 +1567,6 @@ function initDb() {
}
} catch (e) { console.warn('[DB] Role migration:', e.message); }
-- // Migrate: create channel_moderators table if missing
-- try {
-- database.exec(`CREATE TABLE IF NOT EXISTS channel_moderators (
-- id INTEGER PRIMARY KEY AUTOINCREMENT,
-- channel_id INTEGER NOT NULL,
-- user_id INTEGER NOT NULL,
-- added_by INTEGER NOT NULL,
-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-- UNIQUE(channel_id, user_id),
-- FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE,
-- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
-- FOREIGN KEY (added_by) REFERENCES users(id) ON DELETE SET NULL
-- )`);
-- database.exec(`CREATE INDEX IF NOT EXISTS idx_channel_mods_channel ON channel_moderators(channel_id)`);
-- database.exec(`CREATE INDEX IF NOT EXISTS idx_channel_mods_user ON channel_moderators(user_id)`);
-- } catch (e) { console.warn('[DB] channel_moderators migration:', e.message); }
--
-- // Migrate: create channel_moderation_settings table if missing
-- try {
-- database.exec(`CREATE TABLE IF NOT EXISTS channel_moderation_settings (
-- channel_id INTEGER PRIMARY KEY,
-- slow_mode_seconds INTEGER DEFAULT 0,
-- followers_only INTEGER DEFAULT 0,
-- emote_only INTEGER DEFAULT 0,
-- updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-- FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE
-- )`);
-- } catch (e) { console.warn('[DB] channel_moderation_settings migration:', e.message); }
--
-- // Migrate: add extended channel moderation settings columns
-- try {
-- const cols = database.pragma('table_info(channel_moderation_settings)').map(c => c.name);
-- if (!cols.includes('allow_anonymous')) database.exec('ALTER TABLE channel_moderation_settings ADD COLUMN allow_anonymous INTEGER DEFAULT 1');
-- if (!cols.includes('links_allowed')) database.exec('ALTER TABLE channel_moderation_settings ADD COLUMN links_allowed INTEGER DEFAULT 1');
-- if (!cols.includes('gifs_enabled')) database.exec('ALTER TABLE channel_moderation_settings ADD COLUMN gifs_enabled INTEGER DEFAULT 1');
-- if (!cols.includes('account_age_gate_hours')) database.exec('ALTER TABLE channel_moderation_settings ADD COLUMN account_age_gate_hours INTEGER DEFAULT 0');
-- if (!cols.includes('caps_percentage_limit')) database.exec('ALTER TABLE channel_moderation_settings ADD COLUMN caps_percentage_limit INTEGER DEFAULT 0');
-- if (!cols.includes('aggressive_filter')) database.exec('ALTER TABLE channel_moderation_settings ADD COLUMN aggressive_filter INTEGER DEFAULT 0');
-- if (!cols.includes('max_message_length')) database.exec('ALTER TABLE channel_moderation_settings ADD COLUMN max_message_length INTEGER DEFAULT 500');
-- if (!cols.includes('tts_max_length')) database.exec('ALTER TABLE channel_moderation_settings ADD COLUMN tts_max_length INTEGER DEFAULT 200');
-- if (!cols.includes('slur_filter_enabled')) database.exec('ALTER TABLE channel_moderation_settings ADD COLUMN slur_filter_enabled INTEGER DEFAULT 0');
-- if (!cols.includes('slur_filter_use_builtin')) database.exec('ALTER TABLE channel_moderation_settings ADD COLUMN slur_filter_use_builtin INTEGER DEFAULT 1');
-- if (!cols.includes('slur_filter_terms')) database.exec("ALTER TABLE channel_moderation_settings ADD COLUMN slur_filter_terms TEXT DEFAULT ''");
-- if (!cols.includes('slur_filter_regexes')) database.exec("ALTER TABLE channel_moderation_settings ADD COLUMN slur_filter_regexes TEXT DEFAULT ''");
-- if (!cols.includes('slur_filter_nudge_message')) database.exec("ALTER TABLE channel_moderation_settings ADD COLUMN slur_filter_nudge_message TEXT DEFAULT ''");
-- if (!cols.includes('slur_filter_disabled_categories')) database.exec("ALTER TABLE channel_moderation_settings ADD COLUMN slur_filter_disabled_categories TEXT DEFAULT '[]'");
-- if (!cols.includes('soundboard_enabled')) database.exec('ALTER TABLE channel_moderation_settings ADD COLUMN soundboard_enabled INTEGER DEFAULT 1');
-- if (!cols.includes('soundboard_allow_pitch')) database.exec('ALTER TABLE channel_moderation_settings ADD COLUMN soundboard_allow_pitch INTEGER DEFAULT 1');
-- if (!cols.includes('soundboard_allow_speed')) database.exec('ALTER TABLE channel_moderation_settings ADD COLUMN soundboard_allow_speed INTEGER DEFAULT 1');
-- if (!cols.includes('soundboard_banned_ids')) database.exec("ALTER TABLE channel_moderation_settings ADD COLUMN soundboard_banned_ids TEXT DEFAULT ''");
-- if (!cols.includes('viewer_auto_delete_enabled')) database.exec('ALTER TABLE channel_moderation_settings ADD COLUMN viewer_auto_delete_enabled INTEGER DEFAULT 1');
-- if (!cols.includes('viewer_delete_all_enabled')) database.exec('ALTER TABLE channel_moderation_settings ADD COLUMN viewer_delete_all_enabled INTEGER DEFAULT 1');
-- if (!cols.includes('custom_emotes_enabled')) database.exec('ALTER TABLE channel_moderation_settings ADD COLUMN custom_emotes_enabled INTEGER DEFAULT 1');
-- if (!cols.includes('custom_sounds_enabled')) database.exec('ALTER TABLE channel_moderation_settings ADD COLUMN custom_sounds_enabled INTEGER DEFAULT 1');
-- if (!cols.includes('max_sound_seconds')) database.exec('ALTER TABLE channel_moderation_settings ADD COLUMN max_sound_seconds INTEGER DEFAULT 10');
-- if (!cols.includes('uploads_mods_only')) database.exec('ALTER TABLE channel_moderation_settings ADD COLUMN uploads_mods_only INTEGER DEFAULT 0');
-- if (!cols.includes('emote_scale')) database.exec('ALTER TABLE channel_moderation_settings ADD COLUMN emote_scale INTEGER DEFAULT 100');
-- // Per-channel sound pitch/speed limits (speed as a rate; pitch as cents).
-- if (!cols.includes('sound_min_speed')) database.exec('ALTER TABLE channel_moderation_settings ADD COLUMN sound_min_speed REAL DEFAULT 0.5');
-- if (!cols.includes('sound_max_speed')) database.exec('ALTER TABLE channel_moderation_settings ADD COLUMN sound_max_speed REAL DEFAULT 3.0');
-- if (!cols.includes('sound_min_pitch_cents')) database.exec('ALTER TABLE channel_moderation_settings ADD COLUMN sound_min_pitch_cents INTEGER DEFAULT -1200');
-- if (!cols.includes('sound_max_pitch_cents')) database.exec('ALTER TABLE channel_moderation_settings ADD COLUMN sound_max_pitch_cents INTEGER DEFAULT 1200');
-- // Per-channel emote size limits (percent of the base emote height, 100 = default).
-- if (!cols.includes('emote_size_min')) database.exec('ALTER TABLE channel_moderation_settings ADD COLUMN emote_size_min INTEGER DEFAULT 50');
-- if (!cols.includes('emote_size_max')) database.exec('ALTER TABLE channel_moderation_settings ADD COLUMN emote_size_max INTEGER DEFAULT 200');
-- // Sounds-only "mods can upload" flag (independent of the emote uploads_mods_only).
-- if (!cols.includes('sounds_mods_only')) database.exec('ALTER TABLE channel_moderation_settings ADD COLUMN sounds_mods_only INTEGER DEFAULT 0');
-- // Allow channel mods to edit the streamer's About/panels (off by default).
-- if (!cols.includes('mods_can_edit_about')) database.exec('ALTER TABLE channel_moderation_settings ADD COLUMN mods_can_edit_about INTEGER DEFAULT 0');
-- // Sub-only chat: only active subscribers of the channel (and its moderators) may chat (OpenVibe.Chat enforces it).
-- if (!cols.includes('sub_only')) database.exec('ALTER TABLE channel_moderation_settings ADD COLUMN sub_only INTEGER DEFAULT 0');
-- } catch (e) { console.warn('[DB] channel_moderation_settings columns migration:', e.message); }
--
// Migrate: add channel_owner_id to emotes (viewer uploads targeting a channel) + channel_sounds table
try {
const emoteCols = database.prepare('PRAGMA table_info(emotes)').all().map((c) => c.name);
@@@ -2125,14 +2006,6 @@ function initDb() {
database.exec(`CREATE INDEX IF NOT EXISTS idx_hidden_relay_channel ON hidden_relay_users(channel_id, platform)`);
} catch (e) { console.warn('[DB] hidden_relay_users migration:', e.message); }
-- // Migrate: add ip_approval_mode to channel_moderation_settings
-- try {
-- const cols = database.pragma('table_info(channel_moderation_settings)').map(c => c.name);
-- if (!cols.includes('ip_approval_mode')) {
-- database.exec('ALTER TABLE channel_moderation_settings ADD COLUMN ip_approval_mode INTEGER DEFAULT 0');
-- }
-- } catch (e) { console.warn('[DB] ip_approval_mode migration:', e.message); }
--
// Migrate: add deleted_by and deleted_at to chat_messages for soft-delete attribution
try {
const cols = database.pragma('table_info(chat_messages)').map(c => c.name);
@@@ -3991,7 +3864,8 @@ const HOME_SERIES = {
points: { table: 'coin_transactions', ts: 'created_at', agg: 'COALESCE(SUM(amount), 0)', where: 'amount > 0' },
pointsSpent: { table: 'coin_transactions', ts: 'created_at', agg: 'COALESCE(-SUM(amount), 0)', where: 'amount < 0' },
redemptions: { table: 'coin_redemptions', ts: 'created_at', agg: 'COUNT(*)', where: "status NOT IN ('rejected', 'refunded')" },
-- emotes: { table: 'emotes', ts: 'created_at', agg: 'COUNT(*)' },
++ // No `emotes` series: OpenVibe.Chat owns that table (roadmap T3) and Live's copy is unread
++ // since N+2 (unlike the table itself, which N-1 still prepares against — see schema.sql).
};
const HOME_SERIES_KEYS = [...Object.keys(HOME_SERIES), 'liveNow', 'viewersNow'];
@@@ -4180,7 +4054,9 @@ function _computeHomeStats() {
messages: winCount('chat_messages', 'timestamp', 'COALESCE(is_deleted, 0) = 0'),
hours: null, // OpenVibe.Media
streamers: streamersWin(),
-- emotes: winCount('emotes', 'created_at'),
++ // Live's `emotes` copy is unread since N+2 (the series is gone; stats.emotes is null), so the
++ // deltas are zero. The key stays for the N-1 client, which reads stats.recent.emotes.
++ emotes: { d: 0, w: 0, m: 0, pw: 0 },
goals: winCount('donation_goals', 'created_at'),
// Distinct people who tipped in each window — a headcount, like streamers.
supporters: (() => {
@@@ -4856,8 +4732,7 @@ function recordRelayUser(platform, username) {
}
function getRelayUser(platform, username) {
if (!platform || !username) return null;
-- // rowid is a stable integer id for a relay user (no dedicated id column); used to key
-- // their chat-AI insight in chat_ai_summaries.
++ // rowid is a stable integer id for a relay user (no dedicated id column).
return get('SELECT rowid AS id, * FROM relay_users WHERE platform = ? AND username = ?',
[String(platform).toLowerCase(), String(username).toLowerCase()]) || null;
}
ddiff --git a/server/db/migrations.js b/server/db/migrations.js
iindex 580f3f67..32d74cfd 100644
---- a/server/db/migrations.js
++++ b/server/db/migrations.js
@@@ -103,6 +103,34 @@ const MIGRATIONS = [
if (columns(db, 'streams').includes('created_at')) db.exec('CREATE INDEX IF NOT EXISTS idx_streams_created ON streams(created_at)');
},
},
++ {
++ id: '005_drop_chat_staged_tables',
++ // Roadmap T3 N+2. OpenVibe.Chat owns these seven and has since 14:46 UTC; Live's copies are
++ // unread since N+1 (8654ef6, recorded in test/fixtures/n-1/worker.json) and are dropped here.
++ // `emotes` is deliberately left: N-1 still prepares two COUNT(*) against it, so it goes next
++ // release (test/chat-staged-tables.test.js). On a fresh database none of the seven exists and
++ // the migration is adopted.
++ adopt: (db) => ['channel_moderators', 'channel_moderation_settings', 'user_tags',
++ 'chat_ai_summaries', 'chat_timeline_events', 'chat_staged_outbox', 'chat_dual_read_stats']
++ .every((t) => !tableExists(db, t)),
++ up: (db) => {
++ db.exec(`
++ DROP INDEX IF EXISTS idx_channel_mods_channel;
++ DROP INDEX IF EXISTS idx_channel_mods_user;
++ DROP INDEX IF EXISTS idx_chat_ai_scope;
++ DROP INDEX IF EXISTS idx_chat_tl_scope_ts;
++ DROP INDEX IF EXISTS idx_chat_tl_dedup;
++ DROP INDEX IF EXISTS idx_user_tags_user;
++ DROP TABLE IF EXISTS channel_moderators;
++ DROP TABLE IF EXISTS channel_moderation_settings;
++ DROP TABLE IF EXISTS user_tags;
++ DROP TABLE IF EXISTS chat_ai_summaries;
++ DROP TABLE IF EXISTS chat_timeline_events;
++ DROP TABLE IF EXISTS chat_staged_outbox;
++ DROP TABLE IF EXISTS chat_dual_read_stats;
++ `);
++ },
++ },
];
/**
ddiff --git a/server/db/schema.sql b/server/db/schema.sql
iindex 3a6ac2bb..c42ee336 100644
---- a/server/db/schema.sql
++++ b/server/db/schema.sql
@@@ -1,19 +1,8 @@
-- OpenVibe.Live Database Schema
-- SQLite3
---- ── Deletion ledger ─────────────────────────────────────────────────────────
---- OpenVibe.Chat owns the six staged chat tables (roadmap T3) and this release deleted Live's
---- staged-table machinery (server/chat/chat-tables*.js, the /internal/chat-tables routes, Live's
---- chat-AI job and the old emote/moderation routes). The tables stay one release for N-1
---- (ADR-016): the previous release's SQL still prepares against them (test/n-1.test.js).
---- channel_moderators unused since T3 N+1; dropped in the next release.
---- channel_moderation_settings unused since T3 N+1; dropped in the next release.
---- emotes unused since T3 N+1; dropped in the next release.
---- user_tags unused since T3 N+1; dropped in the next release.
---- chat_ai_summaries unused since T3 N+1; dropped in the next release.
---- chat_timeline_events unused since T3 N+1; dropped in the next release.
---- chat_staged_outbox unused since T3 N+1; dropped in the next release.
---- chat_dual_read_stats unused since T3 N+1; dropped in the next release.
++-- emotes: unread since T3 N+2; dropped in the next release (OpenVibe.Chat owns it; N-1 still
++-- prepares two COUNT(*) against it — see test/chat-staged-tables.test.js).
-- Users
CREATE TABLE IF NOT EXISTS users (
@@@ -509,85 +498,6 @@ CREATE INDEX IF NOT EXISTS idx_clips_user_id ON clips(user_id);
CREATE INDEX IF NOT EXISTS idx_bans_stream_id ON bans(stream_id);
CREATE INDEX IF NOT EXISTS idx_cameras_stream_id ON cameras(stream_id);
---- Channel Moderators (per-channel mod assignments)
--CREATE TABLE IF NOT EXISTS channel_moderators (
-- id INTEGER PRIMARY KEY AUTOINCREMENT,
-- channel_id INTEGER NOT NULL,
-- user_id INTEGER NOT NULL,
-- added_by INTEGER NOT NULL,
-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-- UNIQUE(channel_id, user_id),
-- FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE,
-- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
-- FOREIGN KEY (added_by) REFERENCES users(id) ON DELETE SET NULL
--);
--CREATE INDEX IF NOT EXISTS idx_channel_mods_channel ON channel_moderators(channel_id);
--CREATE INDEX IF NOT EXISTS idx_channel_mods_user ON channel_moderators(user_id);
--
---- Per-channel moderation settings (slow mode, etc.)
--CREATE TABLE IF NOT EXISTS channel_moderation_settings (
-- channel_id INTEGER PRIMARY KEY,
-- slow_mode_seconds INTEGER DEFAULT 0,
-- followers_only INTEGER DEFAULT 0,
-- emote_only INTEGER DEFAULT 0,
-- allow_anonymous INTEGER DEFAULT 1,
-- links_allowed INTEGER DEFAULT 1,
-- gifs_enabled INTEGER DEFAULT 1,
-- account_age_gate_hours INTEGER DEFAULT 0,
-- caps_percentage_limit INTEGER DEFAULT 0,
-- aggressive_filter INTEGER DEFAULT 0,
-- max_message_length INTEGER DEFAULT 500,
-- slur_filter_enabled INTEGER DEFAULT 0,
-- slur_filter_use_builtin INTEGER DEFAULT 1,
-- slur_filter_terms TEXT DEFAULT '',
-- slur_filter_regexes TEXT DEFAULT '',
-- slur_filter_nudge_message TEXT DEFAULT '',
-- slur_filter_disabled_categories TEXT DEFAULT '[]',
-- ip_approval_mode INTEGER DEFAULT 0,
-- soundboard_enabled INTEGER DEFAULT 1,
-- soundboard_allow_pitch INTEGER DEFAULT 1,
-- soundboard_allow_speed INTEGER DEFAULT 1,
-- soundboard_banned_ids TEXT DEFAULT '',
-- viewer_auto_delete_enabled INTEGER DEFAULT 1,
-- viewer_delete_all_enabled INTEGER DEFAULT 1,
-- custom_emotes_enabled INTEGER DEFAULT 1, -- viewers may upload gif/png emotes for this channel
-- custom_sounds_enabled INTEGER DEFAULT 1, -- viewers may upload !sound commands for this channel
-- max_sound_seconds INTEGER DEFAULT 10, -- max duration of an uploaded channel sound
-- uploads_mods_only INTEGER DEFAULT 0, -- restrict emote/sound uploads to channel mods
-- mods_can_edit_about INTEGER DEFAULT 0, -- allow channel mods to edit the streamer's About/panels
-- donation_sound_url TEXT, -- on-disk path to the streamer's donation alert sound
-- donation_sound_mime TEXT,
-- goal_sound_url TEXT, -- optional override sound for goal-reached
-- goal_sound_mime TEXT,
-- emote_scale INTEGER DEFAULT 100, -- emote display size in chat, percent (50-300)
-- sub_only INTEGER DEFAULT 0, -- only active subscribers (and the channel's moderators) may chat
-- updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-- FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE
--);
--
---- ── Staged-table bookkeeping, kept one release for N-1 (see the deletion ledger above) ──
---- OpenVibe.Chat owns the six staged chat tables and the machinery that used these two is gone
---- (roadmap T3). The previous release still creates and prepares against them, so they stay here:
---- only their writer was deleted, not the tables (ADR-016).
--CREATE TABLE IF NOT EXISTS chat_staged_outbox (
-- seq INTEGER PRIMARY KEY AUTOINCREMENT,
-- tbl TEXT NOT NULL,
-- op TEXT NOT NULL,
-- pk TEXT NOT NULL,
-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP
--);
--CREATE TABLE IF NOT EXISTS chat_dual_read_stats (
-- tbl TEXT PRIMARY KEY,
-- compared INTEGER NOT NULL DEFAULT 0,
-- matched INTEGER NOT NULL DEFAULT 0,
-- mismatched INTEGER NOT NULL DEFAULT 0,
-- inconclusive INTEGER NOT NULL DEFAULT 0,
-- errors INTEGER NOT NULL DEFAULT 0,
-- last_mismatch_at DATETIME,
-- last_mismatch TEXT,
-- since DATETIME DEFAULT CURRENT_TIMESTAMP
--);
--
-- ═══════════════════════════════════════════════════════════════
-- Site Settings (key/value store for platform configuration)
-- ═══════════════════════════════════════════════════════════════
ddiff --git a/test/chat-context.test.js b/test/chat-context.test.js
iindex 6ba0adec..7b3597dd 100644
---- a/test/chat-context.test.js
++++ b/test/chat-context.test.js
@@@ -107,6 +107,28 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
db.initDb();
require('../server/chat/dm').ensureTables();
const d = db.getDb();
++ // Live no longer has the staged chat tables (dropped in T3 N+2); the stub Chat below imitates
++ // Chat's own copy, so the test keeps local copies to seed and read.
++ d.exec(`
++ CREATE TABLE IF NOT EXISTS channel_moderators (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ channel_id INTEGER NOT NULL,
++ user_id INTEGER NOT NULL,
++ added_by INTEGER NOT NULL,
++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
++ UNIQUE(channel_id, user_id)
++ );
++ CREATE TABLE IF NOT EXISTS channel_moderation_settings (
++ channel_id INTEGER PRIMARY KEY,
++ slow_mode_seconds INTEGER DEFAULT 0,
++ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
++ );
++ CREATE TABLE IF NOT EXISTS emotes (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ user_id INTEGER NOT NULL,
++ channel_owner_id INTEGER
++ );
++ `);
const mkUser = (username, role = 'user', extra = {}) => {
const id = db.createUser({ username, email: `${username}@example.test`, password_hash: '!x', display_name: username.toUpperCase(), stream_key: `key-${username}` }).lastInsertRowid;
d.prepare('UPDATE users SET role = ?, is_owner = ? WHERE id = ?').run(role, extra.owner ? 1 : 0, id);
@@@ -121,7 +143,7 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
db.createChannel({ user_id: streamer, title: 'Streamer TV' });
const channel = db.getChannelByUserId(streamer);
const streamId = Number(db.createStream({ user_id: streamer, channel_id: channel.id, title: 'Live now' }).lastInsertRowid);
-- db.addChannelModerator ? db.addChannelModerator(channel.id, mod, streamer) : d.prepare('INSERT INTO channel_moderators (channel_id, user_id, added_by) VALUES (?, ?, ?)').run(channel.id, mod, streamer);
++ d.prepare('INSERT INTO channel_moderators (channel_id, user_id, added_by) VALUES (?, ?, ?)').run(channel.id, mod, streamer);
d.prepare('INSERT INTO follows (follower_id, streamer_id) VALUES (?, ?)').run(viewer, streamer);
db.setSetting('tts_enabled', 'true');
db.setSetting('stripe_secret_key', 'sk_live_never_shared');
@@@ -333,6 +355,32 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
assert.ok(d.prepare("SELECT 1 FROM stream_first_chats WHERE chatter_key = 'user:77'").get());
assert.strictEqual(d.prepare('SELECT COUNT(*) AS n FROM chat_bridge_outbox').get().n, 0);
++ // 11. Robot commands from chat pass the control panel's gate: control mode, anonymous switch, whitelist, cooldown.
++ {
++ const sent = [];
++ require('../server/controls/control-server').hardwareClients.set('key-streamer', { readyState: 1, send: (m) => sent.push(JSON.parse(m)) });
++ const hw = (body) => call('POST', '/internal/chat-effects/hardware', { token: WRITE, body: { streamer_user_id: streamer, command: 'forward', ...body } });
++ const setCh = (mode, anon) => d.prepare('UPDATE channels SET control_mode = ?, anon_controls_enabled = ? WHERE id = ?').run(mode, anon, channel.id);
++ setCh('open', 1);
++ assert.strictEqual((await hw({ from_anon: 'anon1' })).body.ok, true, 'open channel: anyone');
++ assert.strictEqual((await hw({ from_anon: 'anon1' })).body.reason, 'cooldown', 'one command per viewer per 250 ms');
++ assert.strictEqual((await hw({ from_anon: 'anon2' })).body.ok, true, 'the cooldown is per viewer');
++ setCh('open', 0);
++ assert.strictEqual((await hw({ from_anon: 'anon3' })).body.reason, 'login_required');
++ assert.strictEqual((await hw({ from_user_id: viewer })).body.ok, true, 'signed-in viewers still may');
++ setCh('whitelist', 1);
++ assert.strictEqual((await hw({ from_anon: 'anon4' })).body.reason, 'login_required', 'whitelist mode never lets anonymous drive');
++ assert.strictEqual((await hw({ from_user_id: mod })).body.reason, 'not_whitelisted');
++ d.prepare('INSERT INTO control_whitelist (channel_id, user_id) VALUES (?, ?)').run(channel.id, mod);
++ assert.strictEqual((await hw({ from_user_id: mod })).body.ok, true, 'whitelisted');
++ assert.strictEqual((await hw({ from_user_id: streamer })).body.ok, true, 'the owner always may');
++ setCh('disabled', 1);
++ assert.strictEqual((await hw({ from_user_id: streamer, command: 'say:hi' })).body.reason, 'controls_disabled');
++ assert.strictEqual(sent.length, 5, 'only the allowed commands reached the robot');
++ assert.ok(sent.every((m) => m.type === 'command'));
++ require('../server/controls/control-server').hardwareClients.delete('key-streamer');
++ }
++
quiet('chat context (Live side of OpenVibe.Chat): all checks passed');
} catch (err) {
console.error(err);
ddiff --git a/test/chat-staged-tables.test.js b/test/chat-staged-tables.test.js
iindex c12c009c..eeb6b9f3 100644
---- a/test/chat-staged-tables.test.js
++++ b/test/chat-staged-tables.test.js
@@@ -1,41 +1,39 @@
'use strict';
--// The six staged chat tables (channel_moderators, channel_moderation_settings, emotes, user_tags,
--// chat_ai_summaries, chat_timeline_events) are OpenVibe.Chat's (roadmap T3), and so are the relay
--// bookkeeping tables chat_staged_outbox and chat_dual_read_stats, which belonged to the staged-table
--// machinery this release deleted (server/chat/chat-tables*.js). Like the frozen vods/clips tables
--// (test/frozen-tables.test.js), no server/ or scripts/ code may WRITE them and none may READ them.
--//
--// The tables stay in the schema one more release for N-1 (ADR-016): the previous release's SQL still
--// prepares against them (test/n-1.test.js) and its migrations expect them, so this is a code guard,
--// not a schema one — CREATE/ALTER/PRAGMA/DROP lines are not counted.
--//
--// The two reads that remain are allow-listed, each with a reason:
--// - the home hero's "emotes created" figure and its over-time series read Live's copy of `emotes`
--// while the table is still here; the number is kept for the mixed-version window (an N-1 client
--// reads stats.emotes) and goes with the table.
--// - the one-time rebuild of `emotes` on a pre-existing database (per-channel code uniqueness) is
--// schema upkeep, not a use of the data.
++// The staged chat tables are OpenVibe.Chat's (roadmap T3). This release (N+2) dropped Live's copies
++// of seven of them — channel_moderators, channel_moderation_settings, user_tags, chat_ai_summaries,
++// chat_timeline_events, chat_staged_outbox and chat_dual_read_stats — in the boot migration
++// 005_drop_chat_staged_tables (server/db/migrations.js). `emotes` stays one more release because the
++// N-1 release's SQL still prepares two COUNT(*) against it (test/fixtures/n-1/worker.json), but
++// nothing reads it: the home hero's "emotes created" series is gone and its recent figures are zero
++// (kept for the N-1 client). Like the frozen vods/clips tables (test/frozen-tables.test.js), no
++// server/ or scripts/ code may READ or WRITE any of the dropped tables; the one allowed read is the
++// emotes rebuild that is schema upkeep and goes with the table next release.
const assert = require('assert');
const fs = require('fs');
++const os = require('os');
const path = require('path');
const ROOT = path.join(__dirname, '..');
--const STAGED = [
-- 'channel_moderators', 'channel_moderation_settings', 'emotes', 'user_tags',
++
++// Dropped by 005_drop_chat_staged_tables. `emotes` is deliberately not here (kept one release).
++const DROPPED = [
++ 'channel_moderators', 'channel_moderation_settings', 'user_tags',
'chat_ai_summaries', 'chat_timeline_events', 'chat_staged_outbox', 'chat_dual_read_stats',
];
--const T = STAGED.join('|');
++const T = DROPPED.join('|');
const WRITE = new RegExp(`\\b(INSERT(?:\\s+OR\\s+\\w+)?\\s+INTO|UPDATE|DELETE\\s+FROM|REPLACE\\s+INTO)\\s+(?:main\\.)?(${T})\\b`, 'i');
const READ_SQL = new RegExp(`\\b(FROM|JOIN)\\s+[\`"']?(?:main\\.)?(${T})\\b`, 'gi');
const READ_REGISTRY = new RegExp(`\\btable\\s*:\\s*[\`"'](${T})[\`"']`, 'g');
++// Reads of the one kept table under server/: only the schema upkeep must remain.
++const EMOTES_READ_SQL = /\b(FROM|JOIN)\s+[`"']?(?:main\.)?emotes\b/gi;
++const EMOTES_READ_REGISTRY = /\btable\s*:\s*[`"']emotes[`"']/g;
// { file, snippet, reason }: each must be a real, current read (a stale entry fails the test).
--const ALLOWED_READS = [
-- { file: path.join('server', 'db', 'database.js'), snippet: "table: 'emotes'", reason: 'home hero "emotes created" series; the table stays for the N-1 client that reads stats.emotes' },
-- { file: path.join('server', 'db', 'database.js'), snippet: 'SELECT id, user_id, code, url, animated, width, height, is_global, is_approved, created_at, channel_owner_id, size FROM emotes', reason: 'the one-time pre-existing-database emotes rebuild (per-channel code uniqueness) is schema upkeep' },
++const ALLOWED_EMOTES_READS = [
++ { file: path.join('server', 'db', 'database.js'), snippet: 'FROM emotes', reason: 'the one-time pre-existing-database emotes rebuild (per-channel code uniqueness) is schema upkeep; it goes with the table next release' },
];
function walk(dir, out = []) {
@@@ -46,45 +44,59 @@ function walk(dir, out = []) {
}
return out;
}
--const isCommentLine = (line) => /^\s*(\/\/|\*|\/\*)/.test(line);
++const isCommentLine = (line) => /^\s*(\/\/|\*|\/\*|--)/.test(line);
--/** Every staged-table read in one file's text → [{ line, text }]. */
--function findReads(src) {
++/** Every read matching one of `regexes` in a file's text → [{ line, text }]. */
++function findReads(src, regexes) {
const lines = src.split('\n');
const hits = [];
-- for (const re of [READ_SQL, READ_REGISTRY]) {
++ for (const re of regexes) {
re.lastIndex = 0;
let m;
while ((m = re.exec(src))) {
-- const at = m.index + m[0].length - 1;
-- const lineNo = src.slice(0, at).split('\n').length;
-- const text = lines[lineNo - 1];
const startLine = src.slice(0, m.index).split('\n').length;
-- if (isCommentLine(lines[startLine - 1]) || isCommentLine(text)) continue;
-- hits.push({ line: lineNo, text: text.trim() });
++ const endLine = src.slice(0, m.index + m[0].length - 1).split('\n').length;
++ if (isCommentLine(lines[startLine - 1]) || isCommentLine(lines[endLine - 1])) continue;
++ hits.push({ line: endLine, text: lines[endLine - 1].trim() });
}
}
return hits;
}
// The scanner itself: it has to catch the shapes the deleted machinery used, and leave schema
--// upkeep alone.
--for (const [src, n] of [
-- ["db.get('SELECT * FROM chat_ai_summaries WHERE scope = ?', [scope])", 1],
-- ["db.all(`SELECT cm.id FROM chat_messages cm LEFT JOIN channel_moderators cm2 ON cm2.channel_id = ?`)", 1],
-- ["const S = { emotes: { table: 'emotes', ts: 'created_at', agg: 'COUNT(*)' } };", 1],
-- ["db.run('INSERT INTO emote_media (id) VALUES (1)')", 0],
-- ["db.run('INSERT INTO emotes_new (id, code) VALUES (1, \\'x\\')')", 0],
-- ["db.run('INSERT INTO emotes_new (id, code) SELECT id, code FROM emotes')", 1],
-- ["db.run('UPDATE emotes SET code = ? WHERE id = ?')", 0],
-- ["database.exec('CREATE TABLE IF NOT EXISTS chat_staged_outbox (seq INTEGER)');", 0],
-- ["database.exec('DROP TABLE emotes');", 0],
-- [' // used to read FROM user_tags here', 0],
-- ["db.all('SELECT * FROM stream_timeline_events WHERE stream_id = ?')", 0],
--]) assert.strictEqual(findReads(src).length, n, `scanner on: ${src}`);
++// upkeep (CREATE/DROP/ALTER) and comments alone.
++for (const [src, regexes, n] of [
++ ["db.get('SELECT * FROM chat_ai_summaries WHERE scope = ?', [scope])", [READ_SQL, READ_REGISTRY], 1],
++ ["db.all(`SELECT cm.id FROM chat_messages cm LEFT JOIN channel_moderators cm2 ON cm2.channel_id = ?`)", [READ_SQL, READ_REGISTRY], 1],
++ ["const S = { emotes: { table: 'emotes', ts: 'created_at', agg: 'COUNT(*)' } };", [EMOTES_READ_SQL, EMOTES_READ_REGISTRY], 1],
++ ["db.run('INSERT INTO emote_media (id) VALUES (1)')", [READ_SQL, READ_REGISTRY], 0],
++ ["db.run('INSERT INTO emotes (id, code) VALUES (1, \\'x\\')')", [READ_SQL, READ_REGISTRY], 0],
++ ["db.run('INSERT INTO emotes_new (id, code) SELECT id, code FROM emotes')", [EMOTES_READ_SQL, EMOTES_READ_REGISTRY], 1],
++ ["db.run('UPDATE emotes SET code = ? WHERE id = ?')", [READ_SQL, READ_REGISTRY], 0],
++ ["database.exec('CREATE TABLE IF NOT EXISTS chat_staged_outbox (seq INTEGER)');", [READ_SQL, READ_REGISTRY], 0],
++ ["database.exec('DROP TABLE user_tags');", [READ_SQL, READ_REGISTRY], 0],
++ [' // used to read FROM user_tags here', [READ_SQL, READ_REGISTRY], 0],
++ ["db.all('SELECT * FROM stream_timeline_events WHERE stream_id = ?')", [READ_SQL, READ_REGISTRY], 0],
++]) assert.strictEqual(findReads(src, regexes).length, n, `scanner on: ${src}`);
++
++// After boot the seven are gone, the migration is recorded, and `emotes` is still there for N-1.
++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-chat-staged-'));
++process.env.DB_PATH = path.join(tmp, 'live.db');
++const db = require('../server/db/database');
++db.initDb();
++const d = db.getDb();
++for (const t of DROPPED) {
++ assert.ok(!d.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(t),
++ `${t} still exists after boot — 005_drop_chat_staged_tables did not drop it`);
++}
++assert.ok(d.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'emotes'").get(),
++ 'emotes is kept one more release (N-1 still prepares two COUNT(*) against it)');
++const rec = d.prepare('SELECT mode FROM schema_migrations WHERE id = ?').get('005_drop_chat_staged_tables');
++assert.ok(rec, 'the 005_drop_chat_staged_tables migration is not recorded');
const writes = [];
const reads = [];
++const emoteReads = [];
const usedAllowances = new Set();
for (const file of [...walk(path.join(ROOT, 'server')), ...walk(path.join(ROOT, 'scripts'))]) {
const rel = path.relative(ROOT, file);
@@@ -92,16 +104,19 @@ for (const file of [...walk(path.join(ROOT, 'server')), ...walk(path.join(ROOT,
src.split('\n').forEach((line, i) => {
if (WRITE.test(line) && !isCommentLine(line)) writes.push(`${rel}:${i + 1}: ${line.trim().slice(0, 120)}`);
});
-- for (const hit of findReads(src)) {
-- const ok = ALLOWED_READS.find((a) => a.file === rel && hit.text.includes(a.snippet));
++ for (const hit of findReads(src, [READ_SQL, READ_REGISTRY])) reads.push(`${rel}:${hit.line}: ${hit.text.slice(0, 120)}`);
++ for (const hit of findReads(src, [EMOTES_READ_SQL, EMOTES_READ_REGISTRY])) {
++ const ok = ALLOWED_EMOTES_READS.find((a) => a.file === rel && hit.text.includes(a.snippet));
if (ok) { usedAllowances.add(ok); continue; }
-- reads.push(`${rel}:${hit.line}: ${hit.text.slice(0, 120)}`);
++ emoteReads.push(`${rel}:${hit.line}: ${hit.text.slice(0, 120)}`);
}
}
--assert.deepStrictEqual(writes, [], `writes to Chat's staged tables (OpenVibe.Chat owns them; write through Chat's API instead):\n${writes.join('\n')}`);
--assert.deepStrictEqual(reads, [], `reads of Chat's staged tables (read them through server/chat/moderation-client.js, or Chat's own API):\n${reads.join('\n')}`);
--for (const a of ALLOWED_READS) {
++assert.deepStrictEqual(writes, [], `writes to Chat's dropped tables (OpenVibe.Chat owns them; write through Chat's API instead):\n${writes.join('\n')}`);
++assert.deepStrictEqual(reads, [], `reads of Chat's dropped tables (read them through server/chat/moderation-client.js, or Chat's own API):\n${reads.join('\n')}`);
++assert.deepStrictEqual(emoteReads, [], `reads of \`emotes\` (only the schema upkeep may read it; it goes next release):\n${emoteReads.join('\n')}`);
++for (const a of ALLOWED_EMOTES_READS) {
assert.ok(a.reason && a.reason.length > 20, `allowed read without a reason: ${a.file}`);
assert.ok(usedAllowances.has(a), `stale allow-list entry (the read is gone, remove it): ${a.file}: ${a.snippet}`);
}
--console.log(`chat staged tables: no writes, no unlisted reads — ${STAGED.length} tables, ${ALLOWED_READS.length} allowed read(s)`);
++console.log(`chat staged tables: 005_drop_chat_staged_tables recorded (${rec.mode}) — ${DROPPED.length} tables dropped, emotes kept, ${ALLOWED_EMOTES_READS.length} allowed read(s)`);
++process.exit(0);
ddiff --git a/test/fixtures/destructive-migrations.json b/test/fixtures/destructive-migrations.json
iindex a60aac8a..3362c829 100644
---- a/test/fixtures/destructive-migrations.json
++++ b/test/fixtures/destructive-migrations.json
@@@ -10,6 +10,13 @@
{ "file": "server/db/database.js", "statement": "DROP TABLE CHANNEL_SOUNDS", "count": 1, "contract": "pre-ADR table rebuild keeping every row; guarded, one-time" },
{ "file": "server/db/database.js", "statement": "RENAME TO CHANNEL_SOUNDS", "count": 1, "contract": "the same rebuild" },
{ "file": "server/db/database.js", "statement": "DROP TABLE STREAM_CONTROLS", "count": 1, "contract": "pre-ADR table rebuild keeping every row; guarded, one-time" },
-- { "file": "server/db/database.js", "statement": "RENAME TO STREAM_CONTROLS", "count": 1, "contract": "the same rebuild" }
++ { "file": "server/db/database.js", "statement": "RENAME TO STREAM_CONTROLS", "count": 1, "contract": "the same rebuild" },
++ { "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS CHANNEL_MODERATORS", "count": 1, "contract": "roadmap T3 N+2 (005_drop_chat_staged_tables): OpenVibe.Chat owns the table and N-1 (8654ef6) no longer reads it (test/n-1.test.js); backups kept operator-side" },
++ { "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS CHANNEL_MODERATION_SETTINGS", "count": 1, "contract": "roadmap T3 N+2 (005_drop_chat_staged_tables): OpenVibe.Chat owns the table and N-1 (8654ef6) no longer reads it (test/n-1.test.js); backups kept operator-side" },
++ { "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS USER_TAGS", "count": 1, "contract": "roadmap T3 N+2 (005_drop_chat_staged_tables): OpenVibe.Chat owns the table and N-1 (8654ef6) no longer reads it (test/n-1.test.js); backups kept operator-side" },
++ { "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS CHAT_AI_SUMMARIES", "count": 1, "contract": "roadmap T3 N+2 (005_drop_chat_staged_tables): OpenVibe.Chat owns the table and N-1 (8654ef6) no longer reads it (test/n-1.test.js); backups kept operator-side" },
++ { "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS CHAT_TIMELINE_EVENTS", "count": 1, "contract": "roadmap T3 N+2 (005_drop_chat_staged_tables): OpenVibe.Chat owns the table and N-1 (8654ef6) no longer reads it (test/n-1.test.js); backups kept operator-side" },
++ { "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS CHAT_STAGED_OUTBOX", "count": 1, "contract": "roadmap T3 N+2 (005_drop_chat_staged_tables): the staged-table relay was deleted in N+1 and N-1 (8654ef6) no longer uses it; backups kept operator-side" },
++ { "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS CHAT_DUAL_READ_STATS", "count": 1, "contract": "roadmap T3 N+2 (005_drop_chat_staged_tables): the dual-read machinery was deleted in N+1 and N-1 (8654ef6) no longer uses it; backups kept operator-side" }
]
}
ddiff --git a/test/legacy-game-readonly.test.js b/test/legacy-game-readonly.test.js
iindex 62faea5c..b6a395b3 100644
---- a/test/legacy-game-readonly.test.js
++++ b/test/legacy-game-readonly.test.js
@@@ -78,7 +78,7 @@ for (const [id, name] of [[1, 'veteran'], [2, 'newcomer']]) {
// Chat tags: read-only, and still shown.
const tags = require('../server/chat/tags');
tags.ensureTagTables();
-- raw.prepare("INSERT INTO user_tags (user_id, tag_id, source) VALUES (1, 'legacy', 'migration')").run();
++ // `user_tags` is OpenVibe.Chat's now (dropped in T3 N+2); only the equipped tag is Live's.
raw.prepare("INSERT INTO user_equipped_tag (user_id, tag_id) VALUES (1, 'legacy')").run();
assert.strictEqual(tags.getTagProfile(1).name, 'Legacy');
assert.strictEqual(tags.getTagProfile(2), null);