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.
Changes
@@@ -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@@@ -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@@@ -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` : '' },],});@@@ -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' });@@@ -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) {@@@ -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 tabletry {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 attributiontry {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.Mediastreamers: 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;}@@@ -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;++ `);++ },++ },];/**@@@ -1,19 +1,8 @@-- OpenVibe.Live Database Schema-- SQLite3++-- 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).-- UsersCREATE 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);--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);----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--);----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)-- ═══════════════════════════════════════════════════════════════@@@ -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);@@@ -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);@@@ -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" }]}@@@ -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);
@@ -4,7 +4,7 @@
@@ -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.
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
## Commands
@@ -38,8 +38,8 @@ with the same WebSocket protocol and REST paths. `CHAT_AUTHORITY=chat` switches
@@ -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
`/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)).
`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
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
## Features
@@ -334,7 +334,12 @@ function _renderHeroStats(stats) {
@@ -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-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-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 },
{ 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` : '' },
{ 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` : '' },
],
],
});
});
@@ -493,13 +493,37 @@ effectsRouter.post('/media-queue', async (req, res) => {
@@ -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_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) => {
effectsRouter.post('/hardware', (req, res) => {
const command = String(req.body?.command || '');
const command = String(req.body?.command || '');
if (!HARDWARE_COMMANDS.has(command) && !/^say:[\s\S]{1,200}$/.test(command)) return fail(res, 400, 'unknown 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));
const user = db.getUserById(int(req.body.streamer_user_id));
if (!user) return res.json({ ok: false, reason: 'no_user' });
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 controlServer = require('../controls/control-server');
const hwWs = controlServer.hardwareClients.get(user.stream_key);
const hwWs = controlServer.hardwareClients.get(user.stream_key);
if (!hwWs || hwWs.readyState !== 1) return res.json({ ok: false, reason: 'no_hardware' });
if (!hwWs || hwWs.readyState !== 1) return res.json({ ok: false, reason: 'no_hardware' });
@@ -49,26 +49,16 @@ const TAGS = {
@@ -49,26 +49,16 @@ const TAGS = {
function ensureTagTables() {
function ensureTagTables() {
db.getDb().exec(`
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 (
CREATE TABLE IF NOT EXISTS user_equipped_tag (
user_id INTEGER NOT NULL PRIMARY KEY,
user_id INTEGER NOT NULL PRIMARY KEY,
tag_id TEXT NOT NULL,
tag_id TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
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. */
/** The user's equipped tag, or null. */
function getEquippedTag(userId) {
function getEquippedTag(userId) {
@@ -1325,42 +1325,6 @@ function initDb() {
@@ -1325,42 +1325,6 @@ function initDb() {
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
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).
// Daily easter-egg solves (one per solver per day).
database.exec(`CREATE TABLE IF NOT EXISTS easter_egg_solves (
database.exec(`CREATE TABLE IF NOT EXISTS easter_egg_solves (
id INTEGER PRIMARY KEY AUTOINCREMENT,
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -1539,16 +1503,6 @@ function initDb() {
@@ -1539,16 +1503,6 @@ function initDb() {
if (!cols.includes('sort_order')) database.exec('ALTER TABLE donation_goals ADD COLUMN sort_order INTEGER DEFAULT 0');
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); }
} 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
// chat_messages.metadata — JSON sidecar for rich/animated events (donations + goal
// reached) so they persist in history WITH their structured payload (image, amount,
// reached) so they persist in history WITH their structured payload (image, amount,
// goal title, animation kind) while `message` stays human-readable.
// goal title, animation kind) while `message` stays human-readable.
@@ -1613,79 +1567,6 @@ function initDb() {
@@ -1613,79 +1567,6 @@ function initDb() {
}
}
} catch (e) { console.warn('[DB] Role migration:', e.message); }
} 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
// Migrate: add channel_owner_id to emotes (viewer uploads targeting a channel) + channel_sounds table
try {
try {
const emoteCols = database.prepare('PRAGMA table_info(emotes)').all().map((c) => c.name);
const emoteCols = database.prepare('PRAGMA table_info(emotes)').all().map((c) => c.name);
@@ -2125,14 +2006,6 @@ function initDb() {
@@ -2125,14 +2006,6 @@ function initDb() {
database.exec(`CREATE INDEX IF NOT EXISTS idx_hidden_relay_channel ON hidden_relay_users(channel_id, platform)`);
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); }
} 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
// Migrate: add deleted_by and deleted_at to chat_messages for soft-delete attribution
try {
try {
const cols = database.pragma('table_info(chat_messages)').map(c => c.name);
const cols = database.pragma('table_info(chat_messages)').map(c => c.name);
@@ -3991,7 +3864,8 @@ const HOME_SERIES = {
@@ -3991,7 +3864,8 @@ const HOME_SERIES = {
points: { table: 'coin_transactions', ts: 'created_at', agg: 'COALESCE(SUM(amount), 0)', where: 'amount > 0' },
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' },
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')" },
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'];
const HOME_SERIES_KEYS = [...Object.keys(HOME_SERIES), 'liveNow', 'viewersNow'];
@@ -4180,7 +4054,9 @@ function _computeHomeStats() {
@@ -4180,7 +4054,9 @@ function _computeHomeStats() {
messages: winCount('chat_messages', 'timestamp', 'COALESCE(is_deleted, 0) = 0'),
messages: winCount('chat_messages', 'timestamp', 'COALESCE(is_deleted, 0) = 0'),
hours: null, // OpenVibe.Media
hours: null, // OpenVibe.Media
streamers: streamersWin(),
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'),
goals: winCount('donation_goals', 'created_at'),
// Distinct people who tipped in each window — a headcount, like streamers.
// Distinct people who tipped in each window — a headcount, like streamers.
supporters: (() => {
supporters: (() => {
@@ -4856,8 +4732,7 @@ function recordRelayUser(platform, username) {
@@ -4856,8 +4732,7 @@ function recordRelayUser(platform, username) {
}
}
function getRelayUser(platform, username) {
function getRelayUser(platform, username) {
if (!platform || !username) return null;
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 = ?',
return get('SELECT rowid AS id, * FROM relay_users WHERE platform = ? AND username = ?',
[String(platform).toLowerCase(), String(username).toLowerCase()]) || null;
[String(platform).toLowerCase(), String(username).toLowerCase()]) || null;
}
}
@@ -103,6 +103,34 @@ const MIGRATIONS = [
@@ -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)');
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;
+ `);
+ },
+ },
];
];
/**
/**
@@ -1,19 +1,8 @@
@@ -1,19 +1,8 @@
-- OpenVibe.Live Database Schema
-- OpenVibe.Live Database Schema
-- SQLite3
-- SQLite3
+-- 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
-- Users
CREATE TABLE IF NOT EXISTS users (
CREATE TABLE IF NOT EXISTS users (
@@ -509,85 +498,6 @@ CREATE INDEX IF NOT EXISTS idx_clips_user_id ON clips(user_id);
@@ -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_bans_stream_id ON bans(stream_id);
CREATE INDEX IF NOT EXISTS idx_cameras_stream_id ON cameras(stream_id);
CREATE INDEX IF NOT EXISTS idx_cameras_stream_id ON cameras(stream_id);
-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);
-
-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
-);
-
-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)
-- Site Settings (key/value store for platform configuration)
-- ═══════════════════════════════════════════════════════════════
-- ═══════════════════════════════════════════════════════════════
@@ -107,6 +107,28 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -107,6 +107,28 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
db.initDb();
db.initDb();
require('../server/chat/dm').ensureTables();
require('../server/chat/dm').ensureTables();
const d = db.getDb();
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 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;
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);
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));
@@ -121,7 +143,7 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
db.createChannel({ user_id: streamer, title: 'Streamer TV' });
db.createChannel({ user_id: streamer, title: 'Streamer TV' });
const channel = db.getChannelByUserId(streamer);
const channel = db.getChannelByUserId(streamer);
const streamId = Number(db.createStream({ user_id: streamer, channel_id: channel.id, title: 'Live now' }).lastInsertRowid);
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);
d.prepare('INSERT INTO follows (follower_id, streamer_id) VALUES (?, ?)').run(viewer, streamer);
db.setSetting('tts_enabled', 'true');
db.setSetting('tts_enabled', 'true');
db.setSetting('stripe_secret_key', 'sk_live_never_shared');
db.setSetting('stripe_secret_key', 'sk_live_never_shared');
@@ -333,6 +355,32 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -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.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);
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');
quiet('chat context (Live side of OpenVibe.Chat): all checks passed');
} catch (err) {
} catch (err) {
console.error(err);
console.error(err);
@@ -1,41 +1,39 @@
@@ -1,41 +1,39 @@
'use strict';
'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 assert = require('assert');
const fs = require('fs');
const fs = require('fs');
+const os = require('os');
const path = require('path');
const path = require('path');
const ROOT = path.join(__dirname, '..');
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',
'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 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_SQL = new RegExp(`\\b(FROM|JOIN)\\s+[\`"']?(?:main\\.)?(${T})\\b`, 'gi');
const READ_REGISTRY = new RegExp(`\\btable\\s*:\\s*[\`"'](${T})[\`"']`, 'g');
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).
// { 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 = []) {
function walk(dir, out = []) {
@@ -46,45 +44,59 @@ function walk(dir, out = []) {
@@ -46,45 +44,59 @@ function walk(dir, out = []) {
}
}
return 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 lines = src.split('\n');
const hits = [];
const hits = [];
- for (const re of [READ_SQL, READ_REGISTRY]) {
+ for (const re of regexes) {
re.lastIndex = 0;
re.lastIndex = 0;
let m;
let m;
while ((m = re.exec(src))) {
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;
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;
return hits;
}
}
// The scanner itself: it has to catch the shapes the deleted machinery used, and leave schema
// 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 writes = [];
const reads = [];
const reads = [];
+const emoteReads = [];
const usedAllowances = new Set();
const usedAllowances = new Set();
for (const file of [...walk(path.join(ROOT, 'server')), ...walk(path.join(ROOT, 'scripts'))]) {
for (const file of [...walk(path.join(ROOT, 'server')), ...walk(path.join(ROOT, 'scripts'))]) {
const rel = path.relative(ROOT, file);
const rel = path.relative(ROOT, file);
@@ -92,16 +104,19 @@ for (const file of [...walk(path.join(ROOT, 'server')), ...walk(path.join(ROOT,
@@ -92,16 +104,19 @@ for (const file of [...walk(path.join(ROOT, 'server')), ...walk(path.join(ROOT,
src.split('\n').forEach((line, i) => {
src.split('\n').forEach((line, i) => {
if (WRITE.test(line) && !isCommentLine(line)) writes.push(`${rel}:${i + 1}: ${line.trim().slice(0, 120)}`);
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; }
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(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}`);
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);
@@ -10,6 +10,13 @@
@@ -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": "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": "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": "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" }
]
]
}
}
@@ -78,7 +78,7 @@ for (const [id, name] of [[1, 'veteran'], [2, 'newcomer']]) {
@@ -78,7 +78,7 @@ for (const [id, name] of [[1, 'veteran'], [2, 'newcomer']]) {
// Chat tags: read-only, and still shown.
// Chat tags: read-only, and still shown.
const tags = require('../server/chat/tags');
const tags = require('../server/chat/tags');
tags.ensureTagTables();
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();
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(1).name, 'Legacy');
assert.strictEqual(tags.getTagProfile(2), null);
assert.strictEqual(tags.getTagProfile(2), null);