openvibers/openvibe.chat Commits

Chat serves the features built on the six chat tables (plan T3 step 1): Live's read API GET /internal/moderation/channels/:id, /users/:id/channels and /channels/:id/emote-count (chat.moderation.read, loopback, service token; answers validate against Contracts 0.80.0); the emote API at /api/emotes with Live's paths, shapes, limits and messages, bytes to Media with Chat's own token (the declared type must match the bytes); channel moderators and moderation settings under /api/chat/channels/:id; the playAlertSound bridge op resolving the sound from Chat's own settings row. Writes refuse with 503 until the tables' authority flips. test/chat-apis.test.js (13 checks); 42/42.

OP OpenVibers committed 29 Sept 2026 · 0e38083384 · 16 files, +1738 −16


Changes

ddiff --git a/README.md b/README.md
iindex 45e6315..f764bce 100644
---- a/README.md
++++ b/README.md
@@@ -9,7 +9,7 @@ chat events go to OpenVibe.Events. `openvibe.chat` is Chat's own site (global ch
settings; the service manifest records it live since 2026-09-24).
**Domain:** `openvibe.chat` ([deploy/nginx/openvibe.chat.conf](deploy/nginx/openvibe.chat.conf)); Chat
is also served on Live's origin: `https://openvibe.live/ws/chat`, `/api/chat/`, `/api/dm/`,
--`/api/tts/`, `/api/sounds` via nginx.
++`/api/tts/`, `/api/sounds`, `/api/emotes` via nginx.
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §9 and §9.5.
**License:** AGPL-3.0 (same as every OpenVibe service).
@@@ -59,6 +59,27 @@ before changing behaviour.** Browser JavaScript does not change; nginx routes th
- **Staged tables** (`channel_moderators`, `channel_moderation_settings`, `emotes`, `user_tags`,
`chat_ai_summaries`, `chat_timeline_events`): Live writes them until each is handed to Chat, one
table at a time (`table_authority`, register C-04). Runbook: `docs/staged-tables-cutover.md`.
++- **The features on those tables, served by Chat** (plan T3, plan step 1) — built here so Live can
++ stop touching them at the flip:
++ - `GET /api/emotes/global`, `/channel/:userId`, `/mine`, `/defaults`, `/sources` (GET/PUT),
++ `/search`, `/all/:streamId`, `/file/:filename`, `POST /api/emotes`, `PATCH/DELETE /api/emotes/:id`
++ — Live's exact paths, bodies, limits and error messages; uploaded bytes go to OpenVibe.Media with
++ Chat's own token (`media.object.upload` / `.delete`, namespace `chat`), the row stores
++ `media_url` + `media_asset_id`, responses use `media_url`. No local emote files (`/file/…` 404s).
++ `ffz`/`bttv`/`7tv` are cached provider proxies on fixed hosts.
++ - `GET /api/chat/channels/moderation/mine`, `GET/POST /api/chat/channels/:id/mods`,
++ `DELETE /api/chat/channels/:id/mods/:userId`, `GET/PUT /api/chat/channels/:id/moderation`,
++ `…/moderation/logs`, `…/moderation/chat-search`,
++ `POST …/moderation/messages/:messageId/delete` — owner/admin write, channel mods read.
++ - Live's read API for what Chat owns: `GET /internal/moderation/channels/:channelId`,
++ `GET /internal/moderation/users/:userId/channels`,
++ `GET /internal/moderation/channels/:channelId/emote-count` — loopback + service token,
++ capability `chat.moderation.read` (`chat.channel-moderation-result@1`,
++ `chat.moderated-channels-result@1`, `chat.emote-count-result@1`). Live caches each answer 30 s.
++ - Alert sounds: the bridge op `playAlertSound [streamerId, streamId, kind]` makes Chat resolve the
++ channel's own settings row, read the clip and broadcast it, so Live only names the alert.
++ - Every write keeps the `table_authority` gate: while Live still writes the table the route
++ answers `503 { ok: false, error: 'not yet' }` and never writes.
## How it fits the network
@@@ -68,6 +89,8 @@ before changing behaviour.** Browser JavaScript does not change; nginx routes th
| Accounts, roles, streams, channels, bans, IP approvals, follows, cosmetics, tags, site settings | **Live** (Network for identity) | `server/live-context.js` → `GET/POST /internal/chat-context/*` (service token, `live.chat_context.read`) |
| Coins, AI viewers, arena, media queue, hardware, pastes, translation, PowerChat, notifications | **Live** | `server/live-context.js` → `POST /internal/chat-effects/*` (`live.chat_effects.write`) |
| Live's own chat pushes and writes (AI viewers, relays, donations, `/api/mod`, recaps, calls) | **Live → Chat** | `POST /internal/live/calls` (`chat.live_bridge.write`), presence `GET /internal/live/presence` (`chat.presence.read`) |
++| The six staged tables (moderators, moderation settings, emotes, tags, AI summaries, timeline) | **Chat** (after the T3 flip; Live until then) | Chat's own SQLite; Live reads through `GET /internal/moderation/*` (`chat.moderation.read`), 30 s cached |
++| Emote image bytes | **OpenVibe.Media** (namespace `chat`) | `server/media/client.js` (openvibe-sdk `createObjectsClient`), Chat's service token (`media.object.upload` / `.delete`); the row keeps `media_url` + `media_asset_id` |
| Identity | **OpenVibe.Network** | user tokens are resolved by Live (its account links); service tokens from `/oauth/token` |
| Events | **OpenVibe.Events** | `events_outbox` → `POST /api/v1/events` when `EVENTS_URL` is set (`events.event.publish`); Chat's subscriptions deliver to `POST /internal/events` (`server/events/consumer.js`, `events.subscription.manage`) |
| A person's chat preferences | **OpenVibe.Network** user module `chat.preferences` (Chat owns the namespace) | `server/prefs/` → `GET/PUT/DELETE /internal/modules/chat.preferences/:subject` (`network.modules.read` / `.write`), cached per person |
@@@ -363,7 +386,7 @@ problem+json `rate_limited` with `Retry-After`, one `[Limits]` log line and
| Routes | Per caller |
| --- | --- |
--| Reads of each API (`/api/chat`, `/api/dm`, `/api/tts`, `/api/sounds`, `/api/streams`) | `CHAT_LIMITS_MINUTE` / `CHAT_LIMITS_HOUR` (120 a minute, 3000 an hour) |
++| Reads of each API (`/api/chat`, `/api/dm`, `/api/tts`, `/api/sounds`, `/api/emotes`, `/api/streams`) | `CHAT_LIMITS_MINUTE` / `CHAT_LIMITS_HOUR` (120 a minute, 3000 an hour) |
| GIF search and trending (Tenor or Giphy on the site's key) | 60 / 600 |
| Chat search, staff and streamer logs, purge preview | 30 / 600 |
| Log export, `GET /api/chat/me/export` | 5 / 30 |
@@@ -379,7 +402,7 @@ problem+json `rate_limited` with `Retry-After`, one `[Limits]` log line and
Never limited: `/health`, `/ready`, `/release.json`, `/metrics`, `/internal/*` (the bridge, the call
hooks and the signed Events deliveries, which carry account deletions and merges), `/ws/chat` and
`/ws/call`, the openvibe.chat pages, and the media files players fetch (`/api/tts/audio/…`,
--`/api/sounds/file/…`), which only the per-address limit bounds. `test/actor-limits.test.js`.
++`/api/sounds/file/…`, `/api/emotes/file/…`), which only the per-address limit bounds. `test/actor-limits.test.js`.
### Layout
@@@ -387,7 +410,7 @@ hooks and the signed Events deliveries, which carry account deletions and merges
server/index.js boot: DB, first Live sync, WS server, HTTP app, relays, graceful stop
server/app.js Live's guards for these routes: CORS, /api rate limit, IP bans, ban cookie, WS origin + IP checks
server/live-context.js the only module that talks to Live (interface in its header)
--server/chat/ moved from Live: chat-server, dm, dm-routes, routes, history-store, tts-*, sounds-*, soundboard, moderation-utils, word-filter, deploy-notice
++server/chat/ moved from Live: chat-server, dm, dm-routes, routes, history-store, tts-*, sounds-*, soundboard, moderation-utils, word-filter, deploy-notice; plus the T3 APIs: emotes-routes, channel-mod-routes, internal-moderation (Live's read API), alert-sounds
server/auth/ token resolution through Live; the chat subset of Live's permissions
server/bridge/ Live → Chat calls + presence (live-bridge.js); Chat → Live read mirror (live-mirror.js)
server/events/outbox.js events.event-envelope@1 outbox and relay
@@@ -396,6 +419,7 @@ server/events/subscriptions.js creates them at boot when missing; list/disable/
server/net/service-auth.js service tokens: client (Chat → others) and guard (others → Chat)
server/prefs/ chat preferences in the Network user module chat.preferences (routes, cache, migration from Live)
server/calls/ moved from Live: the call server (/ws/call), its REST routes, Live's stream hooks (/internal/calls), the calls lifecycle
++server/media/client.js OpenVibe.Media objects (namespace chat): emote image bytes, with Chat's own service token
server/db/ schema.sql, database.js (Live's chat functions, same names and arguments)
scripts/ import-from-live, parity-check, parity, mirror-flush, table-authority, migrate-chat-preferences, subscribe-events, n-1-record
docs/ cutover.md, calls-cutover.md, staged-tables-cutover.md, parity.md, live-patch.diff, capabilities-proposal/
ddiff --git a/package-lock.json b/package-lock.json
iindex 17bb12b..24ba2dc 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -17,7 +17,7 @@
"express-rate-limit": "^7.5.0",
"iovalkey": "^0.4.0",
"multer": "^2.0.2",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.80.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.23.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.0.0",
"pg": "^8.23.0",
@@@ -1153,9 +1153,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.79.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",
-- "integrity": "sha512-FY5GLbiOq0kDw5lUPPzX6h3ATbzvsr/mX5DUCF4GRfn6vGMP0p89rZZDhgtVDaS+yCBCYMZ8zFaZxuHEkEVwVg==",
++ "version": "0.80.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.80.0",
++ "integrity": "sha512-7RCNFKxFRey3PquBwyQkSu8Bu2q2Ek0yb248SJWaymcg/g9PrlStFVUDTLMgh8i7BZSaZSngj0nojpwYTLGYDQ==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex 8db2cb1..23a63e1 100644
---- a/package.json
++++ b/package.json
@@@ -29,7 +29,7 @@
"express-rate-limit": "^7.5.0",
"iovalkey": "^0.4.0",
"multer": "^2.0.2",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.80.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.23.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.0.0",
"pg": "^8.23.0",
ddiff --git a/server/app.js b/server/app.js
iindex eaf53be..93f780d 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -98,6 +98,8 @@ function createApp({ chatServer, bridge, mirror, relay, events = null, callServe
if (events) app.use('/internal/events', events.router);
// Live's stream hooks for calls (CALLS_AUTHORITY=chat): stream voice channels (server/calls/internal.js).
app.use('/internal/calls', express.json({ limit: '64kb' }), require('./calls/internal').createInternalRouter());
++ // Live's read API for the chat tables Chat owns (plan T3; capability chat.moderation.read).
++ app.use('/internal/moderation', express.json({ limit: '64kb' }), require('./chat/internal-moderation'));
app.get('/health', (req, res) => res.json({ ok: true, service: 'chat' }));
// Readiness in the openvibe-shared/ready shape (status ready/degraded/not_ready, named checks):
// 503 only when the required check fails. `db` is Chat's own database, which it cannot serve
@@@ -214,17 +216,22 @@ function createApp({ chatServer, bridge, mirror, relay, events = null, callServe
app.use('/api/dm', limits.identify, limits.reads('chat.dm.read'));
app.use('/api/tts', limits.identify, limits.reads('chat.tts.read', { skip: (req) => req.path.startsWith('/audio/') }));
app.use('/api/sounds', limits.identify, limits.reads('chat.sounds.read', { skip: (req) => req.path.startsWith('/file/') }));
++ app.use('/api/emotes', limits.identify, limits.reads('chat.emotes.read', { skip: (req) => req.path.startsWith('/file/') }));
// The person's chat settings: the Network user modules Chat owns (server/prefs/stores.js).
for (const [mount, router] of Object.entries(require('./prefs/routes').ROUTES)) app.use(`/api/chat/${mount}`, router);
// Chat rooms (server/rooms/): before /api/chat, whose /:streamId routes would take /rooms/…
app.use('/api/chat/rooms', require('./rooms/routes'));
++ // Channel moderators & moderation settings (/api/chat/channels/:id/…): before /api/chat too.
++ app.use('/api/chat/channels', require('./chat/channel-mod-routes'));
// ICE servers for call rooms (STUN, and TURN with credentials when TURN_URL is set): server/net/turn.js.
app.use('/api/chat/ice-servers', require('./net/turn').createIceRoutes());
app.use('/api/chat', require('./chat/routes'));
app.use('/api/dm', require('./chat/dm-routes'));
app.use('/api/tts', require('./chat/tts-routes'));
app.use('/api/sounds', require('./chat/sounds-routes'));
++ // Custom emotes (Live's /api/emotes paths, plan T3): bytes go to OpenVibe.Media, rows are Chat's.
++ app.use('/api/emotes', require('./chat/emotes-routes'));
// Calls on Live's paths (server/calls/routes.js): /api/streams/voice-channels… and /api/streams/:id/call.
// Nothing answers here until CHAT_CALLS is on (docs/calls-cutover.md).
const callRoutes = require('./calls/routes');
ddiff --git a/server/bridge/live-bridge.js b/server/bridge/live-bridge.js
iindex 053d04a..f7574ca 100644
---- a/server/bridge/live-bridge.js
++++ b/server/bridge/live-bridge.js
@@@ -9,7 +9,7 @@
* { boot, ops: [{ seq, op, args, ref? }] } → { ok, results: [{ seq, ok, result?, error? }] }
* op = a ChatServer method Live's modules call (broadcastToStream, forwardToGlobal, sendDm,
* synthesizeAndBroadcastTTS, triggerChannelSound, disconnectUser, sendUserUpdate, …), a few
-- * bridge-only ops (sendToConn, deployNotice, userChanged, invalidate, broadcastAllRaw), or `db` — one of
++ * bridge-only ops (sendToConn, deployNotice, playAlertSound, userChanged, invalidate, broadcastAllRaw), or `db` — one of
* the chat-table writes Live's un-moved code still makes (AI viewers, relays, donations,
* /api/mod), executed on Chat's database, which is the authority.
*
@@@ -241,6 +241,13 @@ function createBridge({ chatServer, mirror = null, config = require('../config')
}
case 'deployNotice':
return require('../chat/deploy-notice').announceCommits({ db, chatServer, commits: args[0] });
++ case 'playAlertSound': {
++ // Live's donation / goal-reached alerts: Live says "play the alert for channel X"
++ // (streamerId, streamId, kind), Chat resolves the sound from its own settings row and
++ // broadcasts it to the channel room. Replaces Live reading the file + sending base64.
++ const [streamerId, streamId, kind] = args;
++ return require('../chat/alert-sounds').playAlertSound(chatServer, Number(streamerId) || 0, streamId != null ? Number(streamId) : null, kind === 'goal' ? 'goal' : 'donation');
++ }
case 'userChanged':
ctx.invalidateUser(args[0]);
await ctx.ensureUsers([args[0]]).catch(() => {});
ddiff --git a/server/chat/alert-sounds.js b/server/chat/alert-sounds.js
nnew file mode 100644
iindex 0000000..386afa9
---- /dev/null
++++ b/server/chat/alert-sounds.js
@@@ -0,0 +1,72 @@
++/**
++ * OpenVibe.Chat — channel alert sounds (donation / donation-goal-reached).
++ *
++ * Live's server/monetization/alerts.js used to read the sound file off disk and broadcast it itself
++ * as base64 `soundboard-audio`. At the T3 cutover the settings row and the file are Chat's, so Live
++ * only says "play the donation|goal alert for channel X" over the bridge (op `playAlertSound`); this
++ * module resolves the channel's own channel_moderation_settings row, reads the clip from the shared
++ * sounds directory, and broadcasts it to the channel room (every slot + the offline room), gated on
++ * the viewer's Chat Sounds toggle exactly as before.
++ */
++'use strict';
++
++const fs = require('fs');
++const path = require('path');
++const db = require('../db/database');
++const ctx = require('../live-context');
++
++const MAX_SOUND_BYTES = 3 * 1024 * 1024;
++
++function mimeForExt(ext) {
++ switch ((ext || '').toLowerCase()) {
++ case '.wav': return 'audio/wav';
++ case '.ogg': case '.oga': return 'audio/ogg';
++ case '.opus': return 'audio/opus';
++ case '.webm': return 'audio/webm';
++ case '.m4a': case '.mp4': return 'audio/mp4';
++ case '.aac': return 'audio/aac';
++ case '.flac': return 'audio/flac';
++ default: return 'audio/mpeg';
++ }
++}
++
++function readSound(diskPath, mimeHint) {
++ try {
++ if (!diskPath || !fs.existsSync(diskPath)) return null;
++ const buf = fs.readFileSync(diskPath);
++ if (!buf || !buf.length || buf.length > MAX_SOUND_BYTES) return null;
++ return { audio: buf.toString('base64'), mimeType: mimeHint || mimeForExt(path.extname(diskPath)) };
++ } catch { return null; }
++}
++
++/**
++ * Broadcast a streamer's alert sound to their viewers. kind: 'donation' | 'goal'. The sound comes
++ * from Chat's own settings row for the channel (goal falls back to the donation sound). No-op when
++ * none is configured. → { played: boolean, source } (never throws: a missing sound is not an error).
++ */
++async function playAlertSound(chatServer, streamerId, streamId, kind) {
++ const source = kind === 'goal' ? 'goal-alert' : 'donation-alert';
++ try {
++ if (!chatServer || !chatServer.broadcastToChannelRoom) return { played: false, source };
++ const channel = await ctx.ensureChannelForUser(streamerId);
++ const row = channel ? db.getChannelModerationSettingsRow(channel.id) : null;
++ if (!row) return { played: false, source };
++ let disk, mime;
++ if (kind === 'goal') {
++ disk = row.goal_sound_url || row.donation_sound_url;
++ mime = row.goal_sound_url ? row.goal_sound_mime : row.donation_sound_mime;
++ } else {
++ disk = row.donation_sound_url;
++ mime = row.donation_sound_mime;
++ }
++ const snd = readSound(disk, mime);
++ if (!snd) return { played: false, source };
++ chatServer.broadcastToChannelRoom(streamerId, streamId || null, { type: 'soundboard-audio', audio: snd.audio, mimeType: snd.mimeType, source });
++ return { played: true, source };
++ } catch (err) {
++ console.warn(`[Alerts] play ${kind} for ${streamerId}: ${err.message}`);
++ return { played: false, source };
++ }
++}
++
++module.exports = { playAlertSound, mimeForExt };
ddiff --git a/server/chat/channel-mod-routes.js b/server/chat/channel-mod-routes.js
nnew file mode 100644
iindex 0000000..56d2b3d
---- /dev/null
++++ b/server/chat/channel-mod-routes.js
@@@ -0,0 +1,321 @@
++/**
++ * OpenVibe.Chat — Channel moderator & moderation settings API (moved from OpenVibe.Live
++ * server/admin/channel-mod-routes.js, plan T3 step 1). Mounted under /api/chat/channels (Live's
++ * /api/channels paths, on Chat's origin).
++ *
++ * GET /moderation/mine - Channels the user owns/moderates
++ * GET /:channelId/mods - List channel mods
++ * POST /:channelId/mods - Add channel mod (owner or admin)
++ * DELETE /:channelId/mods/:userId - Remove channel mod (owner or admin)
++ * GET /:channelId/moderation - Get moderation settings (mod+)
++ * PUT /:channelId/moderation - Update settings (mod+; owner for policy)
++ * GET /:channelId/moderation/logs - Moderation action log (mod+)
++ * GET /:channelId/moderation/chat-search - Search channel chat (mod+)
++ * POST /:channelId/moderation/messages/:messageId/delete - Delete a chat message (mod+)
++ *
++ * Channels, users and moderation policy come through live-context; the moderator and settings rows
++ * are Chat's own tables. A write while Live is still the table's writer answers 503
++ * { ok: false, error: 'not yet' } and never writes; after the flip a settings or moderator change
++ * refreshes Chat's policy cache at once (the db helpers call invalidateChannel).
++ */
++'use strict';
++
++const express = require('express');
++const db = require('../db/database');
++const ctx = require('../live-context');
++const { requireAuth } = require('../auth/auth');
++const permissions = require('../auth/permissions');
++
++const router = express.Router();
++
++/** A staged-table write while Live is still the authority: 503, never a write. */
++function notYet(res, err) {
++ if (err && err.code === 'table.not_chat') { res.status(503).json({ ok: false, error: 'not yet' }); return true; }
++ return false;
++}
++
++function parseBoolean(value, fallback) {
++ if (value === undefined || value === null) return fallback;
++ if (typeof value === 'boolean') return value;
++ if (typeof value === 'number') return value !== 0;
++ if (typeof value === 'string') return !['0', 'false', 'off', 'no'].includes(value.toLowerCase());
++ return fallback;
++}
++
++/** Can this user hand out / take away channel mods? Live's canAssignChannelMods: owner or staff. */
++function canAssignMods(user, channel) {
++ return permissions.can(user, 'staff.roles.assign') || (!!channel && channel.user_id === user.id);
++}
++
++/** Middleware: require channel access (owner, channel mod, or staff). */
++function requireChannelAccess(req, res, next) {
++ const channelId = parseInt(req.params.channelId, 10);
++ if (!channelId) return res.status(400).json({ error: 'Invalid channel ID' });
++ const channel = ctx.getChannelById(channelId);
++ if (!channel) return res.status(404).json({ error: 'Channel not found' });
++ if (!permissions.canModerateChannel(req.user, channelId)) {
++ return res.status(403).json({ error: 'Channel moderation access required' });
++ }
++ req.channel = channel;
++ next();
++}
++
++// ── My moderation access ─────────────────────────────────────
++router.get('/moderation/mine', requireAuth, async (req, res) => {
++ try {
++ const owned = await ctx.ensureChannelForUser(req.user.id);
++ const moderated = db.getChannelsByModerator(req.user.id) || [];
++
++ const channelMap = new Map();
++ if (owned) channelMap.set(owned.id, owned);
++ for (const ch of moderated) {
++ const id = Number(ch.channel_id ?? ch.id);
++ if (!channelMap.has(id)) channelMap.set(id, { id, user_id: ch.owner_user_id ?? ch.user_id, title: ch.title });
++ }
++
++ const channels = [...channelMap.values()].map((channel) => ({
++ ...channel,
++ moderation_settings: ctx.getChannelModerationSettings(channel.id),
++ moderators: db.getChannelModerators(channel.id),
++ }));
++
++ res.json({ channels });
++ } catch (err) {
++ console.error('[ChannelMod] Failed to load moderation access:', err.message);
++ res.status(500).json({ error: 'Failed to load channel moderation access' });
++ }
++});
++
++// ── List channel mods ────────────────────────────────────────
++router.get('/:channelId/mods', requireAuth, (req, res) => {
++ try {
++ const channelId = parseInt(req.params.channelId);
++ const channel = ctx.getChannelById(channelId);
++ if (!channel) return res.status(404).json({ error: 'Channel not found' });
++ res.json({ moderators: db.getChannelModerators(channelId), channel_id: channelId });
++ } catch (err) {
++ res.status(500).json({ error: 'Failed to list channel moderators' });
++ }
++});
++
++// ── Add channel mod ──────────────────────────────────────────
++router.post('/:channelId/mods', requireAuth, async (req, res) => {
++ try {
++ const channelId = parseInt(req.params.channelId);
++ const channel = ctx.getChannelById(channelId);
++ if (!channel) return res.status(404).json({ error: 'Channel not found' });
++ if (!canAssignMods(req.user, channel)) {
++ return res.status(403).json({ error: 'Only the channel owner or an admin can add moderators' });
++ }
++ const { username } = req.body;
++ if (!username) return res.status(400).json({ error: 'Username is required' });
++
++ const targetUser = await ctx.ensureUserByUsername(username);
++ if (!targetUser) return res.status(404).json({ error: 'User not found' });
++ if (targetUser.id === channel.user_id) {
++ return res.status(400).json({ error: 'Channel owner is already a moderator' });
++ }
++
++ db.addChannelModerator(channelId, targetUser.id, req.user.id);
++ db.logModerationAction({
++ scope_type: 'channel',
++ scope_id: channelId,
++ actor_user_id: req.user.id,
++ target_user_id: targetUser.id,
++ action_type: 'channel_mod_add',
++ details: { channel_id: channelId, username: targetUser.username },
++ });
++ res.json({ message: `${targetUser.username} added as channel moderator`, moderators: db.getChannelModerators(channelId) });
++ } catch (err) {
++ if (notYet(res, err)) return;
++ res.status(500).json({ error: 'Failed to add channel moderator' });
++ }
++});
++
++// ── Remove channel mod ───────────────────────────────────────
++router.delete('/:channelId/mods/:userId', requireAuth, async (req, res) => {
++ try {
++ const channelId = parseInt(req.params.channelId);
++ const userId = parseInt(req.params.userId);
++ const channel = ctx.getChannelById(channelId);
++ if (!channel) return res.status(404).json({ error: 'Channel not found' });
++ if (!canAssignMods(req.user, channel)) {
++ return res.status(403).json({ error: 'Only the channel owner or an admin can remove moderators' });
++ }
++ const target = ctx.getUserById(userId);
++ db.removeChannelModerator(channelId, userId);
++ db.logModerationAction({
++ scope_type: 'channel',
++ scope_id: channelId,
++ actor_user_id: req.user.id,
++ target_user_id: userId,
++ action_type: 'channel_mod_remove',
++ details: { channel_id: channelId, username: (target && target.username) || String(userId) },
++ });
++ res.json({ message: 'Channel moderator removed', moderators: db.getChannelModerators(channelId) });
++ } catch (err) {
++ if (notYet(res, err)) return;
++ res.status(500).json({ error: 'Failed to remove channel moderator' });
++ }
++});
++
++// ── Get channel moderation settings ──────────────────────────
++router.get('/:channelId/moderation', requireAuth, requireChannelAccess, (req, res) => {
++ try {
++ res.json({ settings: ctx.getChannelModerationSettings(req.channel.id) });
++ } catch (err) {
++ res.status(500).json({ error: 'Failed to get moderation settings' });
++ }
++});
++
++// ── Update channel moderation settings (Live's parsing, clamps and all) ──
++const OWNER_POLICY_KEYS = ['mods_can_edit_about', 'ip_approval_mode', 'uploads_mods_only', 'allow_anonymous', 'sounds_mods_only'];
++router.put('/:channelId/moderation', requireAuth, requireChannelAccess, async (req, res) => {
++ try {
++ const isOwnerOrStaff = req.channel.user_id === req.user.id || permissions.isGlobalModOrAbove(req.user);
++ if (!isOwnerOrStaff) for (const k of OWNER_POLICY_KEYS) delete req.body[k];
++ const settings = db.upsertChannelModerationSettings(req.channel.id, {
++ slow_mode_seconds: (req.body.slow_mode_seconds ?? req.body.slowmode_seconds) !== undefined
++ ? Math.max(0, parseInt(req.body.slow_mode_seconds ?? req.body.slowmode_seconds) || 0) : undefined,
++ followers_only: req.body.followers_only !== undefined ? parseBoolean(req.body.followers_only, false) : undefined,
++ sub_only: req.body.sub_only !== undefined ? parseBoolean(req.body.sub_only, false) : undefined,
++ emote_only: req.body.emote_only,
++ allow_anonymous: req.body.allow_anonymous !== undefined ? parseBoolean(req.body.allow_anonymous, true) : undefined,
++ links_allowed: req.body.links_allowed !== undefined ? parseBoolean(req.body.links_allowed, true) : undefined,
++ gifs_enabled: req.body.gifs_enabled !== undefined ? parseBoolean(req.body.gifs_enabled, true) : undefined,
++ aggressive_filter: req.body.aggressive_filter !== undefined ? parseBoolean(req.body.aggressive_filter, false) : undefined,
++ account_age_gate_hours: req.body.account_age_gate_hours !== undefined ? Math.max(0, parseInt(req.body.account_age_gate_hours) || 0) : undefined,
++ caps_percentage_limit: req.body.caps_percentage_limit !== undefined ? Math.min(100, Math.max(0, parseInt(req.body.caps_percentage_limit) || 70)) : undefined,
++ max_message_length: req.body.max_message_length !== undefined
++ ? Math.min(permissions.isAdmin(req.user) ? 6000 : 4000, Math.max(1, parseInt(req.body.max_message_length) || 500)) : undefined,
++ tts_max_length: req.body.tts_max_length !== undefined ? Math.min(1200, Math.max(10, parseInt(req.body.tts_max_length) || 200)) : undefined,
++ slur_filter_enabled: req.body.slur_filter_enabled !== undefined ? parseBoolean(req.body.slur_filter_enabled, false) : undefined,
++ slur_filter_use_builtin: req.body.slur_filter_use_builtin !== undefined ? parseBoolean(req.body.slur_filter_use_builtin, true) : undefined,
++ slur_filter_terms: req.body.slur_filter_terms !== undefined ? String(req.body.slur_filter_terms || '').slice(0, 4000) : undefined,
++ slur_filter_regexes: req.body.slur_filter_regexes !== undefined ? String(req.body.slur_filter_regexes || '').slice(0, 8000) : undefined,
++ slur_filter_nudge_message: req.body.slur_filter_nudge_message !== undefined ? String(req.body.slur_filter_nudge_message || '').slice(0, 800) : undefined,
++ slur_filter_disabled_categories: req.body.slur_filter_disabled_categories !== undefined
++ ? (() => {
++ const VALID_CATS = new Set(['n_word', 'antisemitic', 'homophobic', 'racial']);
++ let val = req.body.slur_filter_disabled_categories;
++ if (typeof val === 'string') { try { val = JSON.parse(val); } catch { val = []; } }
++ if (!Array.isArray(val)) return '[]';
++ return JSON.stringify(val.filter((k) => typeof k === 'string' && VALID_CATS.has(k)));
++ })() : undefined,
++ ip_approval_mode: req.body.ip_approval_mode !== undefined ? parseBoolean(req.body.ip_approval_mode, false) : undefined,
++ soundboard_enabled: req.body.soundboard_enabled !== undefined ? parseBoolean(req.body.soundboard_enabled, true) : undefined,
++ soundboard_allow_pitch: req.body.soundboard_allow_pitch !== undefined ? parseBoolean(req.body.soundboard_allow_pitch, true) : undefined,
++ soundboard_allow_speed: req.body.soundboard_allow_speed !== undefined ? parseBoolean(req.body.soundboard_allow_speed, true) : undefined,
++ soundboard_banned_ids: req.body.soundboard_banned_ids !== undefined
++ ? (() => {
++ const raw = String(req.body.soundboard_banned_ids || '');
++ const ids = raw.split(/[\s,\n\r]+/).map((s) => {
++ const m = s.trim().match(/101soundboards\.com\/sounds\/(\d+)/i);
++ if (m) return m[1];
++ if (/^\d{2,}$/.test(s.trim())) return s.trim();
++ return null;
++ }).filter(Boolean);
++ return [...new Set(ids)].slice(0, 200).join(',');
++ })() : undefined,
++ viewer_auto_delete_enabled: req.body.viewer_auto_delete_enabled !== undefined ? parseBoolean(req.body.viewer_auto_delete_enabled, true) : undefined,
++ viewer_delete_all_enabled: req.body.viewer_delete_all_enabled !== undefined ? parseBoolean(req.body.viewer_delete_all_enabled, true) : undefined,
++ custom_emotes_enabled: req.body.custom_emotes_enabled !== undefined ? parseBoolean(req.body.custom_emotes_enabled, true) : undefined,
++ custom_sounds_enabled: req.body.custom_sounds_enabled !== undefined ? parseBoolean(req.body.custom_sounds_enabled, true) : undefined,
++ uploads_mods_only: req.body.uploads_mods_only !== undefined ? parseBoolean(req.body.uploads_mods_only, false) : undefined,
++ mods_can_edit_about: req.body.mods_can_edit_about !== undefined ? parseBoolean(req.body.mods_can_edit_about, false) : undefined,
++ max_sound_seconds: req.body.max_sound_seconds !== undefined ? Math.min(30, Math.max(1, parseInt(req.body.max_sound_seconds) || 10)) : undefined,
++ emote_scale: req.body.emote_scale !== undefined ? Math.min(300, Math.max(50, parseInt(req.body.emote_scale) || 100)) : undefined,
++ emote_size_min: req.body.emote_size_min !== undefined ? Math.min(200, Math.max(25, parseInt(req.body.emote_size_min) || 50)) : undefined,
++ emote_size_max: req.body.emote_size_max !== undefined ? Math.min(400, Math.max(50, parseInt(req.body.emote_size_max) || 200)) : undefined,
++ sounds_mods_only: req.body.sounds_mods_only !== undefined ? (req.body.sounds_mods_only ? 1 : 0) : undefined,
++ sound_min_speed: req.body.sound_min_speed !== undefined ? Math.min(1, Math.max(0.1, parseFloat(req.body.sound_min_speed) || 0.5)) : undefined,
++ sound_max_speed: req.body.sound_max_speed !== undefined ? Math.min(5, Math.max(1, parseFloat(req.body.sound_max_speed) || 3.0)) : undefined,
++ sound_min_pitch_cents: req.body.sound_min_pitch_cents !== undefined ? Math.min(0, Math.max(-2400, parseInt(req.body.sound_min_pitch_cents) || -1200)) : undefined,
++ sound_max_pitch_cents: req.body.sound_max_pitch_cents !== undefined ? Math.max(0, Math.min(2400, parseInt(req.body.sound_max_pitch_cents) || 1200)) : undefined,
++ });
++
++ db.logModerationAction({
++ scope_type: 'channel',
++ scope_id: req.channel.id,
++ actor_user_id: req.user.id,
++ action_type: 'channel_settings_update',
++ details: settings.value,
++ });
++ res.json({ settings: settings.value });
++ } catch (err) {
++ if (notYet(res, err)) return;
++ console.error('[ChannelMod] Failed to update moderation settings:', err.message);
++ res.status(500).json({ error: 'Failed to update moderation settings' });
++ }
++});
++
++// ── View moderation action log ───────────────────────────────
++router.get('/:channelId/moderation/logs', requireAuth, requireChannelAccess, (req, res) => {
++ try {
++ const limit = Math.min(parseInt(req.query.limit || '50', 10), 200);
++ const offset = Math.max(parseInt(req.query.offset || '0', 10), 0);
++ res.json({ actions: db.getModerationActions({ scopeType: 'channel', scopeId: req.channel.id, limit, offset }) });
++ } catch (err) {
++ res.status(500).json({ error: 'Failed to load channel moderation log' });
++ }
++});
++
++// ── Search channel chat messages ─────────────────────────────
++router.get('/:channelId/moderation/chat-search', requireAuth, requireChannelAccess, (req, res) => {
++ try {
++ const limit = Math.min(parseInt(req.query.limit || '50', 10), 200);
++ const offset = Math.max(parseInt(req.query.offset || '0', 10), 0);
++ const q = (req.query.q || '').trim();
++ const userId = req.query.user_id ? parseInt(req.query.user_id, 10) : null;
++ res.json(db.searchChannelChatMessages(req.channel, { query: q, userId, limit, offset }));
++ } catch (err) {
++ res.status(500).json({ error: 'Failed to search channel chat' });
++ }
++});
++
++// ── Delete a channel chat message ────────────────────────────
++router.post('/:channelId/moderation/messages/:messageId/delete', requireAuth, requireChannelAccess, (req, res) => {
++ try {
++ const messageId = parseInt(req.params.messageId, 10);
++ const message = db.getChatMessageById(messageId);
++ if (!message) return res.status(404).json({ error: 'Message not found' });
++
++ if (!permissions.isGlobalModOrAbove(req.user)) {
++ let inScope = false;
++ if (message.stream_id) {
++ const stream = ctx.getStreamById(message.stream_id);
++ inScope = !!(stream && (stream.channel_id === req.channel.id || (!stream.channel_id && stream.user_id === req.channel.user_id)));
++ } else if (message.channel_user_id) {
++ inScope = message.channel_user_id === req.channel.user_id;
++ }
++ if (!inScope) return res.status(403).json({ error: 'Message is outside this channel scope' });
++ }
++
++ db.deleteChatMessage(messageId, req.user.id);
++
++ const delPayload = { type: 'delete-messages', ids: [messageId] };
++ const chanUid = message.channel_user_id || (message.stream_id ? (ctx.getStreamById(message.stream_id) || {}).user_id || null : null);
++ try {
++ const chatServer = require('./chat-server');
++ if (chanUid || message.stream_id) chatServer.broadcastToChannelRoom(chanUid, message.stream_id || null, delPayload);
++ if (message.stream_id) chatServer.forwardToGlobal(message.stream_id, delPayload);
++ else if (chanUid) chatServer.forwardToGlobalByChannel(chanUid, delPayload);
++ else chatServer.broadcastGlobal(delPayload);
++ } catch { /* chat server may not be initialized */ }
++
++ db.logModerationAction({
++ scope_type: 'channel',
++ scope_id: req.channel.id,
++ actor_user_id: req.user.id,
++ target_user_id: message.user_id,
++ action_type: 'channel_message_delete',
++ details: { message_id: messageId, stream_id: message.stream_id },
++ });
++ res.json({ message: 'Message deleted', ids: [messageId] });
++ } catch (err) {
++ if (notYet(res, err)) return;
++ res.status(500).json({ error: 'Failed to delete message' });
++ }
++});
++
++module.exports = router;
ddiff --git a/server/chat/emotes-routes.js b/server/chat/emotes-routes.js
nnew file mode 100644
iindex 0000000..c5c9718
---- /dev/null
++++ b/server/chat/emotes-routes.js
@@@ -0,0 +1,673 @@
++/**
++ * OpenVibe.Chat — Emote API (moved from OpenVibe.Live server/emotes/routes.js, plan T3 step 1)
++ *
++ * Same paths, bodies, responses, limits and error messages as Live's /api/emotes, so Live's
++ * frontend (public/js/emotes.js, chat.js, channel-uploads.js, obs/chat.html) works unchanged once
++ * nginx sends /api/emotes here. The rows live in Chat's own emotes table.
++ *
++ * GET /api/emotes/global - Global custom emotes
++ * GET /api/emotes/channel/:userId - Channel emotes for a streamer
++ * GET /api/emotes/mine - My emotes (auth required)
++ * POST /api/emotes - Upload a custom emote (auth required)
++ * PATCH /api/emotes/:id - Rename / resize an emote (auth required)
++ * DELETE /api/emotes/:id - Delete an emote (auth required)
++ * GET /api/emotes/file/:filename - Legacy image file (no local files: 404)
++ * GET /api/emotes/{ffz,bttv,7tv} - Cached provider proxies
++ * GET /api/emotes/defaults - Built-in emote collection
++ * GET/PUT /api/emotes/sources - Per-channel emote source preferences (auth)
++ * GET /api/emotes/search - Search FFZ emotes
++ * GET /api/emotes/all/:streamId - All emotes available in a stream context
++ *
++ * Uploaded bytes go to OpenVibe.Media with Chat's own service token; the row stores media_url and
++ * media_asset_id, and responses use media_url (existing Live rows already carry one). Writes answer
++ * 503 { ok: false, error: 'not yet' } while Live is still the table's writer (table_authority live).
++ */
++'use strict';
++
++const express = require('express');
++const path = require('path');
++const multer = require('multer');
++const db = require('../db/database');
++const ctx = require('../live-context');
++const config = require('../config');
++const media = require('../media/client');
++const { requireAuth, optionalAuth } = require('../auth/auth');
++const permissions = require('../auth/permissions');
++
++const router = express.Router();
++
++const MIME_TO_EXT = { 'image/png': '.png', 'image/jpeg': '.jpg', 'image/gif': '.gif', 'image/webp': '.webp', 'image/avif': '.avif' };
++const ALLOWED_MIME = ['image/png', 'image/gif', 'image/webp', 'image/jpeg', 'image/avif'];
++
++/** The image type a buffer's first bytes say it is (PNG, GIF, WebP, JPEG, AVIF); null for anything else. */
++function sniffImage(buf) {
++ if (!buf || buf.length < 12) return null;
++ if (buf[0] === 0x89 && buf.toString('latin1', 1, 4) === 'PNG') return 'image/png';
++ if (buf.toString('latin1', 0, 4) === 'GIF8') return 'image/gif';
++ if (buf.toString('latin1', 0, 4) === 'RIFF' && buf.toString('latin1', 8, 12) === 'WEBP') return 'image/webp';
++ if (buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return 'image/jpeg';
++ if (buf.toString('latin1', 4, 8) === 'ftyp' && /^avi[fs]$/.test(buf.toString('latin1', 8, 12))) return 'image/avif';
++ return null;
++}
++
++// Bytes are held in memory only long enough to reach Media; nothing is written to Chat's disk.
++const emoteUpload = multer({
++ storage: multer.memoryStorage(),
++ limits: { fileSize: config.emotes.maxSizeKb * 1024 },
++ fileFilter: (req, file, cb) => {
++ if (ALLOWED_MIME.includes(file.mimetype)) return cb(null, true);
++ cb(new Error('Only PNG, GIF, WebP, AVIF, and JPEG images are allowed'));
++ },
++});
++
++// Per-user emote-upload rate limit (sliding window), Live's values: 40 uploads / 15 min per account.
++const EMOTE_UPLOAD_WINDOW_MS = 15 * 60 * 1000;
++const EMOTE_UPLOAD_MAX = 40;
++const _emoteUploadTimes = new Map();
++function emoteUploadRateLimited(userId) {
++ const now = Date.now();
++ const arr = (_emoteUploadTimes.get(userId) || []).filter((t) => now - t < EMOTE_UPLOAD_WINDOW_MS);
++ if (arr.length >= EMOTE_UPLOAD_MAX) { _emoteUploadTimes.set(userId, arr); return true; }
++ arr.push(now);
++ _emoteUploadTimes.set(userId, arr);
++ return false;
++}
++
++/** A staged-table write while Live is still the authority: 503, never a write. */
++function notYet(res, err) {
++ if (err && err.code === 'table.not_chat') { res.status(503).json({ ok: false, error: 'not yet' }); return true; }
++ return false;
++}
++const fileUrl = (e) => e.media_url || `/api/emotes/file/${path.basename(e.url)}`;
++
++// ══════════════════════════════════════════════════════════════
++// FFZ / BTTV / 7TV CACHE
++// ══════════════════════════════════════════════════════════════
++
++let ffzCache = { data: null, ts: 0 };
++let bttvCache = { data: null, ts: 0 };
++let sevenTvCache = { data: null, ts: 0 };
++const ffzSearchCache = new Map();
++
++async function fetchJSON(url) {
++ const res = await fetch(url, {
++ headers: { 'User-Agent': 'OpenVibe.Chat/1.0 (emote-proxy)' },
++ signal: AbortSignal.timeout(8000),
++ });
++ if (!res.ok) throw new Error(`HTTP ${res.status}`);
++ return res.json();
++}
++
++function parseFfzSets(data) {
++ const emotes = [];
++ const sets = data.sets || {};
++ for (const setId of Object.keys(sets)) {
++ const set = sets[setId];
++ for (const e of (set.emoticons || [])) {
++ if (e.hidden) continue;
++ if (e.modifier) continue;
++ emotes.push({
++ id: `ffz-${e.id}`,
++ code: e.name,
++ url: e.urls['2'] || e.urls['1'] || `https://cdn.frankerfacez.com/emote/${e.id}/1`,
++ url_1x: e.urls['1'] || `https://cdn.frankerfacez.com/emote/${e.id}/1`,
++ url_2x: e.urls['2'] || e.urls['1'],
++ url_4x: e.urls['4'] || e.urls['2'] || e.urls['1'],
++ width: e.width || 28,
++ height: e.height || 28,
++ animated: false,
++ source: 'ffz',
++ });
++ }
++ }
++ return emotes;
++}
++
++function parseBttvEmotes(data) {
++ return (Array.isArray(data) ? data : []).map((e) => ({
++ id: `bttv-${e.id}`,
++ code: e.code,
++ url: `https://cdn.betterttv.net/emote/${e.id}/2x`,
++ url_1x: `https://cdn.betterttv.net/emote/${e.id}/1x`,
++ url_2x: `https://cdn.betterttv.net/emote/${e.id}/2x`,
++ url_4x: `https://cdn.betterttv.net/emote/${e.id}/3x`,
++ width: 28,
++ height: 28,
++ animated: e.imageType === 'gif' || e.animated === true,
++ source: 'bttv',
++ }));
++}
++
++function parse7tvEmotes(data) {
++ const emotes = data?.emotes || [];
++ return emotes.map((e) => {
++ const host = e.data?.host;
++ const baseUrl = host?.url || '';
++ const files = host?.files || [];
++ const f2x = files.find((f) => f.name === '2x.webp') || files.find((f) => f.name === '2x.avif') || files.find((f) => f.name === '1x.webp') || files[0];
++ const f1x = files.find((f) => f.name === '1x.webp') || files.find((f) => f.name === '1x.avif') || files[0];
++ const f4x = files.find((f) => f.name === '4x.webp') || files.find((f) => f.name === '3x.webp') || f2x;
++ return {
++ id: `7tv-${e.id}`,
++ code: e.name,
++ url: f2x ? `https:${baseUrl}/${f2x.name}` : '',
++ url_1x: f1x ? `https:${baseUrl}/${f1x.name}` : '',
++ url_2x: f2x ? `https:${baseUrl}/${f2x.name}` : '',
++ url_4x: f4x ? `https:${baseUrl}/${f4x.name}` : '',
++ width: f2x?.width || 28,
++ height: f2x?.height || 28,
++ animated: !!(e.data?.animated),
++ source: '7tv',
++ };
++ }).filter((e) => e.url);
++}
++
++// Live's built-in emote collection (popular community emotes, provider CDN URLs).
++const DEFAULT_EMOTES = [
++ { id: 'def-lul', code: 'LUL', url: 'https://cdn.frankerfacez.com/emote/38010/2', source: 'defaults', animated: false },
++ { id: 'def-lulw', code: 'LULW', url: 'https://cdn.frankerfacez.com/emote/139407/2', source: 'defaults', animated: false },
++ { id: 'def-kekw', code: 'KEKW', url: 'https://cdn.frankerfacez.com/emote/381875/2', source: 'defaults', animated: false },
++ { id: 'def-omegalul', code: 'OMEGALUL', url: 'https://cdn.frankerfacez.com/emote/128054/2', source: 'defaults', animated: false },
++ { id: 'def-pepehands', code: 'PepeHands', url: 'https://cdn.frankerfacez.com/emote/231552/2', source: 'defaults', animated: false },
++ { id: 'def-copium', code: 'Copium', url: 'https://cdn.frankerfacez.com/emote/540942/2', source: 'defaults', animated: false },
++ { id: 'def-monkas', code: 'monkaS', url: 'https://cdn.frankerfacez.com/emote/130762/2', source: 'defaults', animated: false },
++ { id: 'def-ez', code: 'EZ', url: 'https://cdn.frankerfacez.com/emote/425688/2', source: 'defaults', animated: false },
++ { id: 'def-pog', code: 'Pog', url: 'https://cdn.frankerfacez.com/emote/210748/2', source: 'defaults', animated: false },
++ { id: 'def-poggers', code: 'Poggers', url: 'https://cdn.frankerfacez.com/emote/214681/2', source: 'defaults', animated: false },
++ { id: 'def-pogu', code: 'PogU', url: 'https://cdn.betterttv.net/emote/5e4e7a1f08b4447d56a92967/2x', source: 'defaults', animated: false },
++ { id: 'def-peped', code: 'PepeD', url: 'https://cdn.betterttv.net/emote/5b1740221c5a6065a7bad4b5/2x', source: 'defaults', animated: true },
++ { id: 'def-catjam', code: 'catJAM', url: 'https://cdn.betterttv.net/emote/5f1b0186cf6d2144653d2970/2x', source: 'defaults', animated: true },
++ { id: 'def-sadge', code: 'Sadge', url: 'https://cdn.betterttv.net/emote/5e0fa9d40550d42106b8a489/2x', source: 'defaults', animated: false },
++ { id: 'def-pepega', code: 'Pepega', url: 'https://cdn.betterttv.net/emote/5aca62163e290877a25481ad/2x', source: 'defaults', animated: false },
++ { id: 'def-5head', code: '5Head', url: 'https://cdn.betterttv.net/emote/5d6096974932b21d9c332904/2x', source: 'defaults', animated: false },
++ { id: 'def-widehard', code: 'widepeepohappy', url: 'https://cdn.betterttv.net/emote/5e1a76dd8af14b5f1b438c04/2x', source: 'defaults', animated: false },
++ { id: 'def-monkaw', code: 'monkaW', url: 'https://cdn.betterttv.net/emote/59ca6551b27c823d5b1fd872/2x', source: 'defaults', animated: false },
++ { id: 'def-pepelaugh', code: 'PepeLaugh', url: 'https://cdn.betterttv.net/emote/5c548025009a2e73916b3a37/2x', source: 'defaults', animated: false },
++ { id: 'def-modtime', code: 'modCheck', url: 'https://cdn.betterttv.net/emote/5d7eefb7c0652668c9e4d394/2x', source: 'defaults', animated: true },
++ { id: 'def-clap', code: 'CLAP', url: 'https://cdn.betterttv.net/emote/55b6f480e66682f576dd94f5/2x', source: 'defaults', animated: false },
++ { id: 'def-gg', code: 'GGEZ', url: 'https://cdn.frankerfacez.com/emote/703645/2', source: 'defaults', animated: false },
++ { id: 'def-based', code: 'BASED', url: 'https://cdn.frankerfacez.com/emote/768564/2', source: 'defaults', animated: false },
++ { id: 'def-peepo', code: 'peepoHappy', url: 'https://cdn.betterttv.net/emote/5a16ee718c22a247ead62d4a/2x', source: 'defaults', animated: false },
++ { id: 'def-mods', code: 'MODS', url: 'https://cdn.betterttv.net/emote/603451b77c74605395f3295d/2x', source: 'defaults', animated: true },
++].map((e) => ({ ...e, width: 28, height: 28 }));
++
++// ══════════════════════════════════════════════════════════════
++// ROUTES
++// ══════════════════════════════════════════════════════════════
++
++router.get('/global', (req, res) => {
++ try {
++ const emotes = db.getGlobalEmotes().map((e) => ({
++ id: `custom-${e.id}`,
++ code: e.code,
++ url: fileUrl(e),
++ animated: !!e.animated,
++ width: e.width,
++ height: e.height,
++ source: 'custom',
++ owner: e.username,
++ }));
++ res.json({ emotes });
++ } catch (err) {
++ res.status(500).json({ error: 'Failed to load global emotes' });
++ }
++});
++
++router.get('/channel/:userId', (req, res) => {
++ try {
++ const userId = parseInt(req.params.userId);
++ const emotes = db.getChannelEmotes(userId).map((e) => ({
++ id: `custom-${e.id}`,
++ emote_id: e.id,
++ code: e.code,
++ url: fileUrl(e),
++ animated: !!e.animated,
++ width: e.width,
++ height: e.height,
++ source: 'channel',
++ owner: e.username,
++ uploader: e.uploader_display_name || e.uploader_username || e.username,
++ uploader_id: e.user_id,
++ }));
++ res.json({ emotes });
++ } catch (err) {
++ res.status(500).json({ error: 'Failed to load channel emotes' });
++ }
++});
++
++router.get('/mine', requireAuth, (req, res) => {
++ try {
++ const emotes = db.getEmotesByUser(req.user.id).map((e) => ({
++ id: e.id,
++ code: e.code,
++ url: fileUrl(e),
++ animated: !!e.animated,
++ width: e.width,
++ height: e.height,
++ is_global: !!e.is_global,
++ created_at: e.created_at,
++ }));
++ res.json({ emotes, count: db.countChannelEmotes(req.user.id), max: config.emotes.maxPerChannel });
++ } catch (err) {
++ res.status(500).json({ error: 'Failed to load emotes' });
++ }
++});
++
++router.post('/', requireAuth, emoteUpload.single('image'), async (req, res) => {
++ try {
++ if (!req.file) return res.status(400).json({ error: 'No image file uploaded' });
++
++ if (emoteUploadRateLimited(req.user.id)) {
++ return res.status(429).json({ error: 'Too many emote uploads — please wait a bit before uploading more.' });
++ }
++
++ const code = (req.body.code || '').trim();
++ if (!code || code.length < 2 || code.length > 32) {
++ return res.status(400).json({ error: 'Emote code must be 2-32 characters' });
++ }
++ if (!/^[a-zA-Z0-9_]+$/.test(code)) {
++ return res.status(400).json({ error: 'Emote code can only contain letters, numbers, and underscores' });
++ }
++
++ // Optional target channel: channel_id = the streamer's user id; stream_id resolves to its owner.
++ let channelOwnerId = parseInt(req.body.channel_id) || null;
++ if (!channelOwnerId && req.body.stream_id) {
++ channelOwnerId = (await ctx.ensureStream(parseInt(req.body.stream_id)))?.user_id || null;
++ }
++ const isChannelUpload = channelOwnerId && channelOwnerId !== req.user.id;
++
++ if (isChannelUpload) {
++ const channel = await ctx.ensureChannelForUser(channelOwnerId);
++ if (!channel) return res.status(404).json({ error: 'Channel not found' });
++ await ctx.ensurePolicy(channel.id);
++ const settings = ctx.getChannelModerationSettings(channel.id);
++ const isMod = permissions.canModerateChannel(req.user, channel.id);
++ if (!settings.custom_emotes_enabled && !isMod) {
++ return res.status(403).json({ error: 'This streamer has disabled viewer emote uploads.' });
++ }
++ if (settings.uploads_mods_only && !isMod) {
++ return res.status(403).json({ error: 'Only channel mods can upload emotes here.' });
++ }
++ if (db.countChannelEmotes(channelOwnerId) >= config.emotes.maxPerChannel) {
++ return res.status(400).json({ error: `This channel is full (${config.emotes.maxPerChannel} emotes max) — the streamer needs to remove some first.` });
++ }
++ if (db.getChannelEmoteByCode(channelOwnerId, code)) {
++ return res.status(409).json({ error: `This channel already has an emote named "${code}".` });
++ }
++ } else {
++ if (db.countChannelEmotes(req.user.id) >= config.emotes.maxPerChannel) {
++ return res.status(400).json({ error: `Your channel is full (${config.emotes.maxPerChannel} emotes max) — remove some to add more.` });
++ }
++ const existing = db.get('SELECT id FROM emotes WHERE user_id = ? AND code = ? AND channel_owner_id IS NULL', [req.user.id, code]);
++ if (existing) return res.status(409).json({ error: `You already have an emote named "${code}"` });
++ }
++
++ // The declared type must match the bytes: Media serves the object with this type.
++ if (sniffImage(req.file.buffer) !== req.file.mimetype) return res.status(400).json({ error: 'That file is not the image type it claims to be' });
++ const animated = req.file.mimetype === 'image/gif' || req.file.mimetype === 'image/webp';
++ const isGlobal = req.body.is_global === 'true' && permissions.can(req.user, 'staff.assets.manage');
++
++ let sizeMin = 25, sizeMax = 400;
++ if (channelOwnerId) {
++ const channel = await ctx.ensureChannelForUser(channelOwnerId);
++ const st = channel ? ctx.getChannelModerationSettings(channel.id) : null;
++ if (st) { sizeMin = st.emote_size_min || 50; sizeMax = st.emote_size_max || 200; }
++ }
++ const size = Math.min(sizeMax, Math.max(sizeMin, parseInt(req.body.size) || 100));
++
++ const uploaded = await media.uploadObject(req.file.buffer, { filename: req.file.originalname, mimeType: req.file.mimetype });
++ const result = db.createEmote({
++ user_id: req.user.id,
++ code,
++ url: uploaded.url,
++ animated,
++ width: parseInt(req.body.width) || 28,
++ height: parseInt(req.body.height) || 28,
++ is_global: isGlobal,
++ channel_owner_id: isChannelUpload ? channelOwnerId : null,
++ size,
++ media_url: uploaded.url,
++ media_asset_id: uploaded.id,
++ });
++
++ if (isChannelUpload) {
++ try { require('./chat-server').broadcastToOwnerStreams(channelOwnerId, { type: 'emotes-updated' }); } catch { /* */ }
++ }
++ res.json({
++ emote: {
++ id: result.value.lastInsertRowid,
++ code,
++ url: uploaded.url,
++ animated,
++ channel_id: isChannelUpload ? channelOwnerId : undefined,
++ },
++ });
++ } catch (err) {
++ if (notYet(res, err)) return;
++ console.error('[Emotes] Upload error:', err);
++ if (err && /UNIQUE constraint/i.test(err.message || '')) {
++ const code = String((req.body && req.body.code) || '').trim();
++ return res.status(409).json({ error: `This channel already has an emote named "${code}" — pick a different code for this channel.` });
++ }
++ res.status(500).json({ error: 'Failed to upload emote' });
++ }
++});
++
++// Multer error handler (Live's status codes and messages).
++router.use((err, req, res, next) => {
++ if (err.code === 'LIMIT_FILE_SIZE') {
++ return res.status(400).json({ error: `File too large (max ${config.emotes.maxSizeKb}KB)` });
++ }
++ if (err.message && err.message.includes('Only')) {
++ return res.status(400).json({ error: err.message });
++ }
++ console.error('[Emotes] Middleware error:', err);
++ res.status(500).json({ error: 'Emote upload failed' });
++});
++
++router.patch('/:id', requireAuth, async (req, res) => {
++ try {
++ const emote = db.getEmoteById(req.params.id);
++ if (!emote) return res.status(404).json({ error: 'Emote not found' });
++ let allowed = emote.user_id === req.user.id || permissions.can(req.user, 'staff.assets.manage');
++ if (!allowed) {
++ const ownerId = emote.channel_owner_id || emote.user_id;
++ const channel = await ctx.ensureChannelForUser(ownerId);
++ if (channel && permissions.canModerateChannel(req.user, channel.id)) allowed = true;
++ }
++ if (!allowed) return res.status(403).json({ error: 'Not your emote' });
++
++ const patch = {};
++ if (req.body.code !== undefined) {
++ const code = String(req.body.code || '').trim();
++ if (!code || code.length < 2 || code.length > 32) {
++ return res.status(400).json({ error: 'Emote code must be 2-32 characters' });
++ }
++ if (!/^[a-zA-Z0-9_]+$/.test(code)) {
++ return res.status(400).json({ error: 'Emote code can only contain letters, numbers, and underscores' });
++ }
++ const scopeOwner = emote.channel_owner_id || emote.user_id;
++ const clash = (db.getChannelEmotes(scopeOwner) || [])
++ .find((e) => e.id !== emote.id && String(e.code).toLowerCase() === code.toLowerCase());
++ if (clash) return res.status(409).json({ error: `An emote named "${clash.code}" already exists in this channel.` });
++ patch.code = code;
++ }
++ if (req.body.size !== undefined) {
++ let sizeMin = 25, sizeMax = 400;
++ const ownerId = emote.channel_owner_id || emote.user_id;
++ const channel = await ctx.ensureChannelForUser(ownerId);
++ const st = channel ? ctx.getChannelModerationSettings(channel.id) : null;
++ if (st) { sizeMin = st.emote_size_min || 50; sizeMax = st.emote_size_max || 200; }
++ patch.size = Math.min(sizeMax, Math.max(sizeMin, parseInt(req.body.size) || 100));
++ }
++ if (patch.code === undefined && patch.size === undefined) {
++ return res.status(400).json({ error: 'Nothing to change' });
++ }
++
++ db.updateEmote(emote.id, patch);
++ if (patch.code && patch.code !== emote.code) {
++ try { db.updateChannelSoundEmoteRefs(emote.channel_owner_id || emote.user_id, emote.code, patch.code); } catch { /* */ }
++ }
++ if (emote.channel_owner_id) {
++ try { require('./chat-server').broadcastToOwnerStreams(emote.channel_owner_id, { type: 'emotes-updated' }); } catch { /* */ }
++ }
++ res.json({ message: 'Emote updated', code: patch.code ?? emote.code, size: patch.size ?? emote.size });
++ } catch (err) {
++ if (notYet(res, err)) return;
++ res.status(500).json({ error: 'Failed to update emote' });
++ }
++});
++
++router.delete('/:id', requireAuth, async (req, res) => {
++ try {
++ const emote = db.getEmoteById(req.params.id);
++ if (!emote) return res.status(404).json({ error: 'Emote not found' });
++ let allowed = emote.user_id === req.user.id || permissions.can(req.user, 'staff.assets.manage');
++ if (!allowed) {
++ const ownerId = emote.channel_owner_id || emote.user_id;
++ const channel = await ctx.ensureChannelForUser(ownerId);
++ if (channel && permissions.canModerateChannel(req.user, channel.id)) allowed = true;
++ }
++ if (!allowed) return res.status(403).json({ error: 'Not your emote' });
++
++ db.deleteEmote(emote.id);
++ await media.deleteObject(emote.media_asset_id);
++ if (emote.channel_owner_id) {
++ try { require('./chat-server').broadcastToOwnerStreams(emote.channel_owner_id, { type: 'emotes-updated' }); } catch { /* */ }
++ }
++ res.json({ message: 'Emote deleted' });
++ } catch (err) {
++ if (notYet(res, err)) return;
++ res.status(500).json({ error: 'Failed to delete emote' });
++ }
++});
++
++// Legacy image files: Chat keeps none. Bytes live on OpenVibe.Media now; a row that still lacks a
++// media_url has no file here either, so this answers 404 for everything (Live's frontend only uses
++// this path for rows without media_url, which are served from the provider/Media URLs instead).
++router.get('/file/:filename', (req, res) => {
++ res.status(404).json({ error: 'Emote file not found' });
++});
++
++// ── FFZ / BTTV / 7TV (cached proxies) ────────────────────────
++router.get('/ffz', async (req, res) => {
++ try {
++ const now = Date.now();
++ if (ffzCache.data && (now - ffzCache.ts) < config.emotes.ffzCacheTtl * 1000) {
++ return res.json({ emotes: ffzCache.data, cached: true });
++ }
++ const data = await fetchJSON('https://api.frankerfacez.com/v1/set/global');
++ const emotes = parseFfzSets(data);
++ ffzCache = { data: emotes, ts: now };
++ res.json({ emotes, cached: false });
++ } catch (err) {
++ if (ffzCache.data) return res.json({ emotes: ffzCache.data, cached: true, stale: true });
++ res.status(502).json({ error: 'Failed to fetch FFZ emotes', emotes: [] });
++ }
++});
++
++router.get('/bttv', async (req, res) => {
++ try {
++ const now = Date.now();
++ if (bttvCache.data && (now - bttvCache.ts) < config.emotes.bttvCacheTtl * 1000) {
++ return res.json({ emotes: bttvCache.data, cached: true });
++ }
++ const data = await fetchJSON('https://api.betterttv.net/3/cached/emotes/global');
++ const emotes = parseBttvEmotes(data);
++ bttvCache = { data: emotes, ts: now };
++ res.json({ emotes, cached: false });
++ } catch (err) {
++ if (bttvCache.data) return res.json({ emotes: bttvCache.data, cached: true, stale: true });
++ res.status(502).json({ error: 'Failed to fetch BTTV emotes', emotes: [] });
++ }
++});
++
++router.get('/7tv', async (req, res) => {
++ try {
++ const now = Date.now();
++ if (sevenTvCache.data && (now - sevenTvCache.ts) < config.emotes.sevenTvCacheTtl * 1000) {
++ return res.json({ emotes: sevenTvCache.data, cached: true });
++ }
++ const data = await fetchJSON('https://7tv.io/v3/emote-sets/global');
++ const emotes = parse7tvEmotes(data);
++ sevenTvCache = { data: emotes, ts: now };
++ res.json({ emotes, cached: false });
++ } catch (err) {
++ if (sevenTvCache.data) return res.json({ emotes: sevenTvCache.data, cached: true, stale: true });
++ res.status(502).json({ error: 'Failed to fetch 7TV emotes', emotes: [] });
++ }
++});
++
++router.get('/defaults', (req, res) => {
++ res.json({ emotes: DEFAULT_EMOTES });
++});
++
++// ── Emote source preferences (stored on Live's channels row) ──
++router.get('/sources', requireAuth, async (req, res) => {
++ try {
++ const channel = await ctx.ensureChannelForUser(req.user.id);
++ res.json({ sources: ctx.emoteSources(channel && channel.emote_sources) });
++ } catch (err) {
++ res.status(500).json({ error: 'Failed to load emote sources' });
++ }
++});
++
++router.put('/sources', requireAuth, async (req, res) => {
++ try {
++ const sources = {};
++ for (const key of ['defaults', 'custom', 'ffz', 'bttv', '7tv']) {
++ sources[key] = req.body[key] !== false && req.body[key] !== 'false';
++ }
++ await ctx.setChannelEmoteSources(req.user.id, sources);
++ res.json({ sources, message: 'Emote sources updated' });
++ } catch (err) {
++ res.status(500).json({ error: 'Failed to update emote sources' });
++ }
++});
++
++// ── Search FFZ emotes ────────────────────────────────────────
++router.get('/search', async (req, res) => {
++ try {
++ const q = (req.query.q || '').trim();
++ if (!q || q.length < 2) return res.json({ emotes: [] });
++
++ const cacheKey = q.toLowerCase();
++ const cached = ffzSearchCache.get(cacheKey);
++ const now = Date.now();
++ if (cached && (now - cached.ts) < config.emotes.ffzCacheTtl * 1000) {
++ return res.json({ emotes: cached.data, cached: true });
++ }
++
++ const data = await fetchJSON(`https://api.frankerfacez.com/v1/emotes?q=${encodeURIComponent(q)}&per_page=50&sort=count-desc`);
++ const emotes = (data.emoticons || []).filter((e) => !e.hidden && !e.modifier).map((e) => ({
++ id: `ffz-${e.id}`,
++ code: e.name,
++ url: e.urls?.['2'] || e.urls?.['1'] || `https://cdn.frankerfacez.com/emote/${e.id}/1`,
++ url_1x: e.urls?.['1'] || `https://cdn.frankerfacez.com/emote/${e.id}/1`,
++ url_2x: e.urls?.['2'] || e.urls?.['1'],
++ animated: false,
++ source: 'ffz',
++ usage_count: e.usage_count || 0,
++ }));
++
++ ffzSearchCache.set(cacheKey, { data: emotes, ts: now });
++ if (ffzSearchCache.size > 200) {
++ const oldest = [...ffzSearchCache.entries()].sort((a, b) => a[1].ts - b[1].ts).slice(0, 100);
++ for (const [key] of oldest) ffzSearchCache.delete(key);
++ }
++ res.json({ emotes, cached: false });
++ } catch (err) {
++ res.status(502).json({ error: 'Search failed', emotes: [] });
++ }
++});
++
++// ── All emotes for a stream context ──────────────────────────
++router.get('/all/:streamId', optionalAuth, async (req, res) => {
++ try {
++ const streamId = parseInt(req.params.streamId);
++ const stream = streamId ? await ctx.ensureStream(streamId) : null;
++ let streamUserId = stream ? stream.user_id : null;
++ if (!streamUserId && req.query.channel) {
++ try { const u = await ctx.ensureUserByUsername(String(req.query.channel)); if (u) streamUserId = u.id; } catch { /* */ }
++ }
++
++ let sources = { defaults: true, custom: true, ffz: true, bttv: true, '7tv': true };
++ let channel = null;
++ if (streamUserId) {
++ channel = await ctx.ensureChannelForUser(streamUserId);
++ if (channel && channel.emote_sources) sources = ctx.emoteSources(channel.emote_sources);
++ }
++
++ const defaults = sources.defaults !== false ? DEFAULT_EMOTES : [];
++
++ let globalCustom = [];
++ let channelEmotes = [];
++ if (sources.custom !== false) {
++ globalCustom = db.getGlobalEmotes().map((e) => ({
++ id: `custom-${e.id}`,
++ code: e.code,
++ url: fileUrl(e),
++ animated: !!e.animated,
++ width: e.width,
++ height: e.height,
++ size: e.size || 100,
++ source: 'custom',
++ owner: e.username,
++ }));
++
++ if (streamUserId) {
++ channelEmotes = db.getChannelEmotes(streamUserId).filter((e) => !e.is_global).map((e) => ({
++ id: `custom-${e.id}`,
++ emote_id: e.id,
++ code: e.code,
++ url: fileUrl(e),
++ animated: !!e.animated,
++ width: e.width,
++ height: e.height,
++ size: e.size || 100,
++ source: 'channel',
++ owner: e.username,
++ uploader: e.uploader_display_name || e.uploader_username || e.username,
++ uploader_id: e.user_id,
++ }));
++ }
++ }
++
++ let ffzEmotes = [];
++ if (sources.ffz !== false) {
++ ffzEmotes = ffzCache.data || [];
++ if (!ffzEmotes.length) {
++ try {
++ const data = await fetchJSON('https://api.frankerfacez.com/v1/set/global');
++ ffzEmotes = parseFfzSets(data);
++ ffzCache = { data: ffzEmotes, ts: Date.now() };
++ } catch { /* use empty */ }
++ }
++ }
++
++ let bttvEmotes = [];
++ if (sources.bttv !== false) {
++ bttvEmotes = bttvCache.data || [];
++ if (!bttvEmotes.length) {
++ try {
++ const data = await fetchJSON('https://api.betterttv.net/3/cached/emotes/global');
++ bttvEmotes = parseBttvEmotes(data);
++ bttvCache = { data: bttvEmotes, ts: Date.now() };
++ } catch { /* use empty */ }
++ }
++ }
++
++ let sevenTvEmotes = [];
++ if (sources['7tv'] !== false) {
++ sevenTvEmotes = sevenTvCache.data || [];
++ if (!sevenTvEmotes.length) {
++ try {
++ const data = await fetchJSON('https://7tv.io/v3/emote-sets/global');
++ sevenTvEmotes = parse7tvEmotes(data);
++ sevenTvCache = { data: sevenTvEmotes, ts: Date.now() };
++ } catch { /* use empty */ }
++ }
++ }
++
++ let emoteScale = 100;
++ if (channel) {
++ const st = ctx.getChannelModerationSettings(channel.id);
++ if (st && st.emote_scale) emoteScale = Number(st.emote_scale) || 100;
++ }
++
++ res.json({
++ defaults,
++ channel: channelEmotes,
++ global: globalCustom,
++ ffz: ffzEmotes,
++ bttv: bttvEmotes,
++ '7tv': sevenTvEmotes,
++ sources,
++ emote_scale: emoteScale,
++ });
++ } catch (err) {
++ res.status(500).json({ error: 'Failed to load emotes' });
++ }
++});
++
++module.exports = router;
ddiff --git a/server/chat/internal-moderation.js b/server/chat/internal-moderation.js
nnew file mode 100644
iindex 0000000..74ce1e8
---- /dev/null
++++ b/server/chat/internal-moderation.js
@@@ -0,0 +1,70 @@
++/**
++ * OpenVibe.Chat — Live's internal read API for the chat tables Chat owns (plan T3).
++ *
++ * Live keeps only its own features; what it needs from the tables Chat now writes it asks here,
++ * loopback only, with a service token for audience openvibe.chat carrying `chat.moderation.read`:
++ *
++ * GET /internal/moderation/channels/:channelId { ok, settings, moderator_ids }
++ * GET /internal/moderation/users/:userId/channels { ok, channels: [{ channel_id, title, owner_user_id, owner_username }] }
++ * GET /internal/moderation/channels/:channelId/emote-count { ok, count }
++ *
++ * The answers are the contracts chat.channel-moderation-result@1, chat.moderated-channels-result@1
++ * and chat.emote-count-result@1. `settings` is every channel_moderation_settings column, with
++ * Live's defaults when the channel has no row. `channelId` is a channels.id for the settings and
++ * moderator reads; for the emote count it is the channel owner's Live user id (the emotes rows are
++ * keyed by the streamer), the same value Live's db.countChannelEmotes() takes.
++ */
++'use strict';
++
++const express = require('express');
++const db = require('../db/database');
++const ctx = require('../live-context');
++const serviceAuth = require('../net/service-auth');
++
++const router = express.Router();
++const guard = serviceAuth.guard('chat.moderation.read');
++
++router.get('/channels/:channelId', guard, (req, res) => {
++ try {
++ const channelId = parseInt(req.params.channelId, 10);
++ if (!channelId) return res.status(400).json({ ok: false, error: 'Invalid channel ID' });
++ const settings = db.getChannelModerationSettingsRow(channelId) || ctx.defaultModerationSettings(channelId);
++ const moderator_ids = db.getChannelModeratorIds(channelId);
++ res.json({ ok: true, settings, moderator_ids });
++ } catch (err) {
++ console.error('[Moderation] channel read:', err.message);
++ res.status(500).json({ ok: false, error: 'Failed to load channel moderation' });
++ }
++});
++
++router.get('/users/:userId/channels', guard, (req, res) => {
++ try {
++ const userId = parseInt(req.params.userId, 10);
++ if (!userId) return res.status(400).json({ ok: false, error: 'Invalid user ID' });
++ const channels = db.getChannelsByModerator(userId).map((c) => ({
++ channel_id: Number(c.channel_id),
++ title: c.title == null ? null : String(c.title),
++ owner_user_id: c.owner_user_id == null ? null : Number(c.owner_user_id),
++ owner_username: c.owner_username == null ? null : String(c.owner_username),
++ }));
++ res.json({ ok: true, channels });
++ } catch (err) {
++ console.error('[Moderation] moderated channels read:', err.message);
++ res.status(500).json({ ok: false, error: 'Failed to load moderated channels' });
++ }
++});
++
++router.get('/channels/:channelId/emote-count', guard, (req, res) => {
++ try {
++ const raw = parseInt(req.params.channelId, 10);
++ if (!raw) return res.status(400).json({ ok: false, error: 'Invalid channel ID' });
++ // A channels.id resolves to its owner's user id (the emotes' key); a user id is used as it is.
++ const ownerId = (ctx.getChannelById(raw) || {}).user_id || raw;
++ res.json({ ok: true, count: db.countChannelEmotes(ownerId) });
++ } catch (err) {
++ console.error('[Moderation] emote count:', err.message);
++ res.status(500).json({ ok: false, error: 'Failed to count emotes' });
++ }
++});
++
++module.exports = router;
ddiff --git a/server/config.js b/server/config.js
iindex 4fdd636..ca1b94a 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -140,6 +140,26 @@ module.exports = {
ringTimeoutMs: int(process.env.CALL_RING_TIMEOUT_MS, 45_000),
},
++ // Custom emotes (Live's /api/emotes moved here, plan T3 step 1). Uploaded image bytes go to
++ // OpenVibe.Media (media, below); the limits and provider cache TTLs are Live's own values.
++ emotes: {
++ maxSizeKb: int(process.env.MAX_EMOTE_SIZE_KB, 2048),
++ maxPerUser: int(process.env.MAX_EMOTES_PER_USER, 30),
++ maxPerChannel: int(process.env.MAX_EMOTES_PER_CHANNEL, 30),
++ maxPerUploaderPerChannel: int(process.env.MAX_EMOTES_PER_UPLOADER_PER_CHANNEL, 30),
++ ffzCacheTtl: int(process.env.FFZ_CACHE_TTL, 3600),
++ bttvCacheTtl: int(process.env.BTTV_CACHE_TTL, 3600),
++ sevenTvCacheTtl: int(process.env.SEVENTV_CACHE_TTL, 3600),
++ },
++
++ // OpenVibe.Media — where emote image bytes live now (Media API v2 objects, namespace `chat`).
++ // Chat's own service token (client `chat`) needs media.object.upload and media.object.delete.
++ media: {
++ baseUrl: strip(process.env.OV_MEDIA_INTERNAL_URL || 'http://127.0.0.1:4100'),
++ publicOrigin: strip(process.env.OV_MEDIA_PUBLIC_URL || 'https://openvibe.media'),
++ scope: 'media.object.upload media.object.delete',
++ },
++
// Channel sound clips live on disk. At cutover this is Live's sounds directory: stored rows
// hold absolute paths into it, and Live's donation alerts read the alert sounds from there.
sounds: {
ddiff --git a/server/db/database.js b/server/db/database.js
iindex 24bdbe1..3323049 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -118,6 +118,7 @@ const ADDED_COLUMNS = [
['emotes', 'media_asset_id', 'INTEGER'],
['ctx_users', 'subject_id', 'TEXT'], // older Chat databases; auth/network-session.js looks users up by it
['channel_moderation_settings', 'sub_only', 'INTEGER DEFAULT 0'], // sub-only chat (WS-I task 6), Live has it too
++ ['ctx_channels', 'emote_sources', 'TEXT'], // the channel's emote source switches (T3, Live's channels row)
];
function initDb({ captureMirror = false } = {}) {
@@@ -846,6 +847,152 @@ function updateChannelSoundEmoteRefs(ownerId, oldCode, newCode) {
[newCode || '', ownerId, oldCode]);
}
++// ── The six staged tables: reads for the public APIs and Live's internal read API (T3) ──
++// Same SQL and shapes as Live's database.js helpers of the same name; usernames come from the
++// ctx_users projection instead of Live's users table.
++
++function isChannelModerator(userId, channelId) {
++ return !!get('SELECT 1 FROM channel_moderators WHERE user_id = ? AND channel_id = ?', [userId, channelId]);
++}
++
++/** The channel_moderation_settings row as it is, or null when the channel has none (defaults are Live's). */
++function getChannelModerationSettingsRow(channelId) {
++ return get('SELECT * FROM channel_moderation_settings WHERE channel_id = ?', [Number(channelId)]) || null;
++}
++
++/** A channel's moderators, with names, oldest first (Live's getChannelModerators). */
++function getChannelModerators(channelId) {
++ return all(`
++ SELECT cm.id, cm.user_id, cm.added_by, cm.created_at,
++ u.username, u.display_name, u.avatar_url,
++ a.username as added_by_username
++ FROM channel_moderators cm
++ LEFT JOIN ctx_users u ON cm.user_id = u.id
++ LEFT JOIN ctx_users a ON cm.added_by = a.id
++ WHERE cm.channel_id = ?
++ ORDER BY cm.created_at ASC, cm.id ASC
++ `, [channelId]);
++}
++
++/** The channel's moderator user ids, oldest first (Live's policy answer carries the same list). */
++function getChannelModeratorIds(channelId) {
++ return all('SELECT user_id FROM channel_moderators WHERE channel_id = ? ORDER BY created_at ASC, id ASC', [channelId]).map((r) => r.user_id);
++}
++
++/**
++ * The channels a user moderates (Live's getChannelsByModerator). Channel title and owner come from
++ * the ctx_channels / ctx_users projections; the extra `id` alias is what the dashboard reads.
++ */
++function getChannelsByModerator(userId) {
++ return all(`
++ SELECT cm.channel_id, cm.channel_id AS id, c.title,
++ c.user_id AS user_id, c.user_id AS owner_user_id, u.username AS owner_username
++ FROM channel_moderators cm
++ LEFT JOIN ctx_channels c ON cm.channel_id = c.id
++ LEFT JOIN ctx_users u ON c.user_id = u.id
++ WHERE cm.user_id = ?
++ ORDER BY cm.created_at ASC, cm.id ASC
++ `, [userId]);
++}
++
++/** Custom emotes marked global (Live's getGlobalEmotes). */
++function getGlobalEmotes() {
++ return all(`SELECT e.*, u.username FROM emotes e
++ LEFT JOIN ctx_users u ON e.user_id = u.id
++ WHERE e.is_global = 1 AND e.is_approved = 1 ORDER BY code`);
++}
++
++/** A channel's emotes: ones targeted at the owner, plus the owner's own legacy uploads. */
++function getChannelEmotes(userId) {
++ return all(
++ `SELECT e.*, u.username, up.username AS uploader_username, up.display_name AS uploader_display_name
++ FROM emotes e
++ LEFT JOIN ctx_users u ON e.user_id = u.id
++ LEFT JOIN ctx_users up ON e.user_id = up.id
++ WHERE ((e.channel_owner_id = ?) OR (e.channel_owner_id IS NULL AND e.user_id = ?))
++ AND e.is_approved = 1
++ ORDER BY code`,
++ [userId, userId]
++ );
++}
++
++/** How many emotes a channel holds (Live's countChannelEmotes; ownerId is the streamer's user id). */
++function countChannelEmotes(ownerId) {
++ return get(
++ 'SELECT COUNT(*) as count FROM emotes WHERE (channel_owner_id = ?) OR (channel_owner_id IS NULL AND user_id = ?)',
++ [ownerId, ownerId]
++ )?.count || 0;
++}
++
++/** The channel's emote with this code, any uploader (Live's getChannelEmoteByCode). */
++function getChannelEmoteByCode(ownerId, code) {
++ return get(
++ `SELECT * FROM emotes
++ WHERE code = ? AND ((channel_owner_id = ?) OR (channel_owner_id IS NULL AND user_id = ?))
++ LIMIT 1`,
++ [code, ownerId, ownerId]
++ );
++}
++
++/** One emote by id, with its uploader's name (Live's getEmoteById). */
++function getEmoteById(id) {
++ return get('SELECT e.*, u.username FROM emotes e LEFT JOIN ctx_users u ON e.user_id = u.id WHERE e.id = ?', [id]);
++}
++
++/** A user's own emotes (Live's getEmotesByUser). */
++function getEmotesByUser(userId) {
++ return all('SELECT * FROM emotes WHERE user_id = ? ORDER BY code', [userId]);
++}
++
++/** Moderation actions with actor/target names (Live's getModerationActions). */
++function getModerationActions({ scopeType, scope_type, scopeId, scope_id, actor_user_id, target_user_id, limit = 50, offset = 0 } = {}) {
++ const conditions = [];
++ const params = [];
++ const st = scopeType || scope_type;
++ const si = scopeId || scope_id;
++ if (st) { conditions.push('ma.scope_type = ?'); params.push(st); }
++ if (si) { conditions.push('ma.scope_id = ?'); params.push(si); }
++ if (actor_user_id) { conditions.push('ma.actor_user_id = ?'); params.push(actor_user_id); }
++ if (target_user_id) { conditions.push('ma.target_user_id = ?'); params.push(target_user_id); }
++ const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
++ params.push(limit, offset);
++ return all(`
++ SELECT ma.*, actor.username AS actor_username, target.username AS target_username
++ FROM moderation_actions ma
++ LEFT JOIN ctx_users actor ON ma.actor_user_id = actor.id
++ LEFT JOIN ctx_users target ON ma.target_user_id = target.id
++ ${where}
++ ORDER BY ma.created_at DESC
++ LIMIT ? OFFSET ?
++ `, params);
++}
++
++/**
++ * A channel's chat messages matching a search (Live's searchChannelChatMessages): one of the
++ * channel's streams, or its offline room (channel_user_id), never another channel's room.
++ */
++function searchChannelChatMessages(channel, { query, userId, limit = 50, offset = 0 } = {}) {
++ const conditions = [
++ '(cm.channel_user_id = ? OR cm.stream_id IN (SELECT id FROM ctx_streams WHERE channel_id = ? OR user_id = ?))',
++ 'cm.is_deleted = 0',
++ '(cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > CURRENT_TIMESTAMP)',
++ ];
++ const params = [channel.user_id, channel.id, channel.user_id];
++ if (query) { conditions.push('cm.message LIKE ?'); params.push(`%${query}%`); }
++ if (userId) { conditions.push('cm.user_id = ?'); params.push(userId); }
++ params.push(limit, offset);
++ return {
++ messages: all(`
++ SELECT cm.*, u.username, u.display_name, u.avatar_url
++ FROM chat_messages cm
++ LEFT JOIN ctx_users u ON cm.user_id = u.id
++ WHERE ${conditions.join(' AND ')}
++ ORDER BY cm.timestamp DESC
++ LIMIT ? OFFSET ?
++ `, params),
++ };
++}
++
// ── Staged tables (roadmap C-04) ──────────────────────────────
// Who writes each one is table_authority (STAGED_TABLES above). The reads are cached for a second:
// a handoff made by another process (scripts/table-authority.js) counts within that.
@@@ -1053,14 +1200,15 @@ function setChannelAlertSound(channelId, kind, url, mime) {
return out;
}
--// Emotes (Live's /api/emotes; media_url/media_asset_id from its Media asset-sync).
--function createEmote({ user_id, code, url, animated = false, width = 28, height = 28, is_global = false, channel_owner_id = null, size = 100 }) {
++// Emotes (Live's /api/emotes; media_url/media_asset_id are OpenVibe.Media's copy — Live's
++// asset-sync filled them onto the shared rows, Chat uploads through its own Media token now).
++function createEmote({ user_id, code, url, animated = false, width = 28, height = 28, is_global = false, channel_owner_id = null, size = 100, media_url = null, media_asset_id = null }) {
_assertChatWrites('emotes');
return transaction(() => {
const res = run(
-- `INSERT INTO emotes (user_id, code, url, animated, width, height, is_global, channel_owner_id, size)
-- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
-- [user_id, code, url, animated ? 1 : 0, width, height, is_global ? 1 : 0, channel_owner_id || null, Math.min(400, Math.max(25, parseInt(size, 10) || 100))]
++ `INSERT INTO emotes (user_id, code, url, animated, width, height, is_global, channel_owner_id, size, media_url, media_asset_id)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
++ [user_id, code, url, animated ? 1 : 0, width, height, is_global ? 1 : 0, channel_owner_id || null, Math.min(400, Math.max(25, parseInt(size, 10) || 100)), media_url || null, media_asset_id || null]
);
return _staged(res, 'emotes', [get('SELECT * FROM emotes WHERE id = ?', [res.lastInsertRowid])]);
});
@@@ -1296,6 +1444,20 @@ module.exports = {
deleteChannelSound,
renameChannelSoundCommand,
updateChannelSoundEmoteRefs,
++ // the six staged tables: reads for the public APIs and Live's internal read API (T3)
++ isChannelModerator,
++ getChannelModerationSettingsRow,
++ getChannelModerators,
++ getChannelModeratorIds,
++ getChannelsByModerator,
++ getGlobalEmotes,
++ getChannelEmotes,
++ countChannelEmotes,
++ getChannelEmoteByCode,
++ getEmoteById,
++ getEmotesByUser,
++ getModerationActions,
++ searchChannelChatMessages,
// staged tables (C-04): authority, Live's changes, dual-read slices, and the writes once Chat owns them
tableAuthority,
setTableAuthority,
ddiff --git a/server/db/schema.sql b/server/db/schema.sql
iindex 7ba013f..7c35d17 100644
---- a/server/db/schema.sql
++++ b/server/db/schema.sql
@@@ -362,6 +362,7 @@ CREATE TABLE IF NOT EXISTS ctx_channels (
id INTEGER PRIMARY KEY,
user_id INTEGER UNIQUE,
title TEXT,
++ emote_sources TEXT, -- the channel's emote source switches (Live's channels.emote_sources)
synced_at INTEGER
);
ddiff --git a/server/live-context.js b/server/live-context.js
iindex 5d9226c..f58e5c1 100644
---- a/server/live-context.js
++++ b/server/live-context.js
@@@ -207,7 +207,19 @@ class Swr {
const USER_COLS = ['id', 'username', 'display_name', 'avatar_url', 'profile_color', 'role', 'is_banned', 'ban_reason', 'is_owner', 'created_at', 'subject_id'];
const STREAM_COLS = ['id', 'user_id', 'channel_id', 'managed_stream_id', 'title', 'is_live', 'started_at', 'ended_at', 'created_at'];
const MS_COLS = ['id', 'user_id', 'slug', 'title', 'sort_order', 'created_at'];
--const CHANNEL_COLS = ['id', 'user_id', 'title'];
++const CHANNEL_COLS = ['id', 'user_id', 'title', 'emote_sources'];
++
++// The emote source switches Live keeps on the channels row (emote_sources JSON), every key defaulting
++// to true: Live's /api/emotes/sources answers exactly this shape.
++const EMOTE_SOURCE_KEYS = ['defaults', 'custom', 'ffz', 'bttv', '7tv'];
++function emoteSources(raw) {
++ let sources = raw;
++ if (typeof raw === 'string') { try { sources = JSON.parse(raw || '{}'); } catch { sources = {}; } }
++ if (!sources || typeof sources !== 'object') sources = {};
++ const out = {};
++ for (const key of EMOTE_SOURCE_KEYS) out[key] = sources[key] === undefined ? true : sources[key] !== false;
++ return out;
++}
function upsertRows(table, cols, rows) {
if (!rows || !rows.length) return 0;
@@@ -334,6 +346,14 @@ async function createChannel(userId) {
return getChannelByUserId(userId);
}
++/** Write the channel's emote source switches (Live's channels.emote_sources) and refresh the cache. */
++async function setChannelEmoteSources(userId, sources) {
++ const clean = emoteSources(sources);
++ await effect('channel-emote-sources', { user_id: userId, sources: clean });
++ db.run('UPDATE ctx_channels SET emote_sources = ? WHERE user_id = ?', [JSON.stringify(clean), Number(userId)]);
++ return clean;
++}
++
// ── Channel policy: moderation settings, moderators, language, alert sounds ──────────────────
// Everyone who wants to know when a channel's settings were read anew (the chat server announces a
@@@ -939,6 +959,7 @@ module.exports = {
// streams + channels
getStreamById, ensureStream, refreshStream, latestStreamIdForUser, getLiveStreamsByUserId, getStreamsByUserId, getManagedStreamsByUserId,
getChannelById, getChannelByUserId, ensureChannelForUser, createChannel,
++ emoteSources, setChannelEmoteSources,
// policy
getChannelModerationSettings, isChannelModerator, channelLanguage, getChannelAlertSoundsByUser, ensurePolicy, invalidateChannel,
reloadPolicy, onChannelSettings, defaultModerationSettings,
ddiff --git a/server/media/client.js b/server/media/client.js
nnew file mode 100644
iindex 0000000..e81867f
---- /dev/null
++++ b/server/media/client.js
@@@ -0,0 +1,56 @@
++/**
++ * OpenVibe.Chat → OpenVibe.Media (object API v2, namespace `chat`).
++ *
++ * Chat's OWN service token (client `chat`, capability media.object.upload to store an emote's
++ * image and media.object.delete to remove it) through openvibe-sdk/media's createObjectsClient.
++ * An uploaded object is public, and its public openvibe.media URL is what an emote row stores as
++ * media_url (Live's asset-sync did the same onto the shared rows before the T3 takeover).
++ */
++'use strict';
++
++const config = require('../config');
++
++const APP = 'chat';
++
++let _objects = null;
++
++function objects() {
++ if (_objects) return _objects;
++ if (!config.oauth.clientSecret) throw new Error('OV_OAUTH_CLIENT_SECRET is not set');
++ const { createServiceTokenClient } = require('openvibe-sdk/auth');
++ const { createObjectsClient } = require('openvibe-sdk/media');
++ const tokenClient = createServiceTokenClient({
++ tokenUrl: `${config.networkInternalUrl}/oauth/token`,
++ clientId: config.oauth.clientId,
++ clientSecret: config.oauth.clientSecret,
++ audience: 'openvibe.media',
++ scope: config.media.scope,
++ });
++ _objects = createObjectsClient({ app: APP, baseUrl: config.media.baseUrl, tokenClient });
++ return _objects;
++}
++
++/** The absolute public URL of an uploaded object (Media returns public_url; url is the fallback). */
++function publicUrl(obj) {
++ if (!obj) return null;
++ if (obj.public_url) return obj.public_url;
++ if (obj.url) return /^https?:\/\//i.test(obj.url) ? obj.url : `${config.media.publicOrigin}${String(obj.url).startsWith('/') ? '' : '/'}${obj.url}`;
++ return null;
++}
++
++/**
++ * Upload image bytes as a public Media object → { id, url }.
++ * Shaped for the emote routes: `id` is stored as media_asset_id, `url` as media_url.
++ */
++async function uploadObject(buffer, { filename, mimeType } = {}) {
++ const obj = await objects().upload(buffer, { kind: 'file', visibility: 'public', mimeType: mimeType || 'application/octet-stream', filename });
++ return { id: obj.id, url: publicUrl(obj) };
++}
++
++/** Remove a Media object (an emote or sound was deleted). Best-effort: logged, never thrown. */
++async function deleteObject(id) {
++ if (!id) return false;
++ try { return await objects().delete(id); } catch (err) { console.warn(`[Media] delete ${id}: ${err.message}`); return false; }
++}
++
++module.exports = { uploadObject, deleteObject, publicUrl, APP, _setClient(c) { _objects = c; }, _reset() { _objects = null; } };
ddiff --git a/test/chat-apis.test.js b/test/chat-apis.test.js
nnew file mode 100644
iindex 0000000..36ab5ec
---- /dev/null
++++ b/test/chat-apis.test.js
@@@ -0,0 +1,287 @@
++'use strict';
++/**
++ * Plan T3 step 1 — the APIs Chat serves for the six staged tables:
++ * - Live's internal read API (/internal/moderation/*, capability chat.moderation.read)
++ * - the public emote API (/api/emotes, Live's paths and shapes; bytes on OpenVibe.Media)
++ * - channel moderators & moderation settings (/api/chat/channels/:id/…)
++ * - the bridge alert-sound op (Live says "play the alert", Chat resolves the sound)
++ * The authority-not-flipped refusal (503 { ok:false, error:'not yet' }) is checked for every writer.
++ */
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const { validate } = require('openvibe-contracts');
++const { boot, suite } = require('./helpers');
++
++const t = suite('chat-apis');
++let h, RO, BRIDGE, streamer, other, admin, mod, stranger, channelId, streamId;
++const STAGED_TABLES = ['channel_moderators', 'channel_moderation_settings', 'emotes'];
++const deletedAssets = [];
++let assets = { upload: null, delete: null };
++
++function checkContract(ref, body) {
++ const r = validate(ref, body);
++ assert.ok(r.valid, `${ref} invalid: ${JSON.stringify(r.errors)} (${JSON.stringify(body)})`);
++}
++const asChat = (table) => h.db.setTableAuthority(table, 'chat');
++
++t('boot', async () => {
++ // 3 emotes per channel, so both the clash check and the cap can be exercised.
++ h = await boot({ env: { MAX_EMOTES_PER_CHANNEL: '3' } });
++ RO = h.serviceToken(['chat.moderation.read']);
++ BRIDGE = h.serviceToken(['chat.live_bridge.write']);
++ streamer = h.addUser('streamer', { role: 'streamer' });
++ other = h.addUser('otherstreamer', { role: 'streamer' });
++ admin = h.addUser('adminny', { role: 'admin' });
++ mod = h.addUser('moddy');
++ stranger = h.addUser('stranger');
++ channelId = h.addChannel(streamer.id, { moderators: [mod.id], settings: { channel_id: 0, custom_emotes_enabled: 1 } });
++ streamId = h.addStream(streamer.id, channelId);
++ await h.ctx.sync();
++ // The Media mock: uploads answer a public object, deletes are recorded.
++ assets.upload = async (buf, opts) => ({ id: 777, public_url: `https://openvibe.media/chat/${(opts && opts.filename) || 'emote.png'}` });
++ assets.delete = async (id) => { deletedAssets.push(id); return true; };
++ require('../server/media/client')._setClient({ upload: assets.upload, delete: assets.delete });
++});
++
++// ── Internal read API ────────────────────────────────────────
++t('internal read API: loopback + service token (401 / 403 / forwarded)', async () => {
++ let r = await h.http('GET', `/internal/moderation/channels/${channelId}`);
++ assert.strictEqual(r.status, 401, r.text);
++ r = await h.http('GET', `/internal/moderation/channels/${channelId}`, { token: BRIDGE, headers: { 'x-forwarded-for': '203.0.113.9' } });
++ assert.strictEqual(r.status, 403, 'a forwarded request is refused (loopback only)');
++ r = await h.http('GET', `/internal/moderation/channels/${channelId}`, { token: BRIDGE });
++ assert.strictEqual(r.status, 403, 'wrong capability');
++ r = await h.http('GET', `/api/internal/moderation/channels/${channelId}`, { token: RO });
++ assert.strictEqual(r.status, 404, 'not a public path');
++});
++
++t('internal read API: settings + moderator_ids validate, defaults for a channel with no row', async () => {
++ const r = await h.http('GET', `/internal/moderation/channels/${channelId}`, { token: RO });
++ assert.strictEqual(r.status, 200, r.text);
++ checkContract('chat.channel-moderation-result', r.body);
++ assert.strictEqual(r.body.ok, true);
++ assert.strictEqual(r.body.settings.channel_id, channelId);
++ // Live's defaults when the channel has no settings row.
++ const d = await h.http('GET', '/internal/moderation/channels/999999', { token: RO });
++ checkContract('chat.channel-moderation-result', d.body);
++ assert.strictEqual(d.body.settings.channel_id, 999999);
++ assert.strictEqual(d.body.settings.max_message_length, 500);
++ assert.strictEqual(d.body.settings.allow_anonymous, 1);
++ assert.deepStrictEqual(d.body.moderator_ids, []);
++});
++
++t('internal read API: moderator_ids oldest first; the channels a user moderates validate', async () => {
++ asChat('channel_moderators');
++ h.db.addChannelModerator(channelId, mod.id, streamer.id);
++ h.db.run('UPDATE channel_moderators SET created_at = ? WHERE user_id = ?', ['2026-01-01 00:00:00', mod.id]);
++ h.db.addChannelModerator(channelId, stranger.id, streamer.id);
++ h.db.run('UPDATE channel_moderators SET created_at = ? WHERE user_id = ?', ['2026-02-01 00:00:00', stranger.id]);
++ const r = await h.http('GET', `/internal/moderation/channels/${channelId}`, { token: RO });
++ checkContract('chat.channel-moderation-result', r.body);
++ assert.deepStrictEqual(r.body.moderator_ids, [mod.id, stranger.id]);
++
++ // other moderates nothing; mod moderates one channel (title/owner from the projection).
++ let c = await h.http('GET', `/internal/moderation/users/${mod.id}/channels`, { token: RO });
++ checkContract('chat.moderated-channels-result', c.body);
++ assert.strictEqual(c.body.channels.length, 1);
++ assert.strictEqual(c.body.channels[0].channel_id, channelId);
++ assert.strictEqual(c.body.channels[0].owner_user_id, streamer.id);
++ assert.strictEqual(c.body.channels[0].owner_username, 'streamer');
++ assert.strictEqual(c.body.channels[0].title, 'Channel');
++ c = await h.http('GET', `/internal/moderation/users/${other.id}/channels`, { token: RO });
++ checkContract('chat.moderated-channels-result', c.body);
++ assert.deepStrictEqual(c.body.channels, []);
++});
++
++t('internal read API: emote count validates; channels.id resolves to its owner', async () => {
++ const r = await h.http('GET', `/internal/moderation/channels/${channelId}/emote-count`, { token: RO });
++ assert.strictEqual(r.status, 200, r.text);
++ checkContract('chat.emote-count-result', r.body);
++ assert.strictEqual(r.body.count, 0);
++});
++
++// ── Emote API ────────────────────────────────────────────────
++const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, 0x49, 0x48, 0x44, 0x52]);
++async function upload(userId, token, fields = {}, bytes = PNG, type = 'image/png') {
++ const form = new FormData();
++ form.append('image', new Blob([bytes], { type }), 'emote.png');
++ for (const [k, v] of Object.entries(fields)) form.append(k, String(v));
++ const res = await fetch(`${h.base}/api/emotes`, { method: 'POST', headers: { Authorization: `Bearer ${token}` }, body: form });
++ return { status: res.status, body: await res.json().catch(() => null) };
++}
++
++t('emote API: the authority-not-flipped refusal (503 not yet, nothing written)', async () => {
++ const r = await upload(streamer.id, streamer.token, { code: 'refused' });
++ assert.strictEqual(r.status, 503, JSON.stringify(r.body));
++ assert.deepStrictEqual(r.body, { ok: false, error: 'not yet' });
++ assert.strictEqual(h.db.get('SELECT COUNT(*) AS n FROM emotes').n, 0);
++});
++
++t('emote API: create uploads to Media, lists in Live\'s shapes, clash and cap', async () => {
++ asChat('emotes');
++ // Public list shapes need no auth.
++ let r = await h.http('GET', '/api/emotes/defaults');
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(r.body.emotes.length, 25);
++
++ const up = await upload(streamer.id, streamer.token, { code: 'hype', size: 150 });
++ assert.strictEqual(up.status, 200, JSON.stringify(up.body));
++ assert.strictEqual(up.body.emote.code, 'hype');
++ assert.strictEqual(up.body.emote.url, 'https://openvibe.media/chat/emote.png');
++ const row = h.db.get('SELECT * FROM emotes WHERE code = ?', ['hype']);
++ assert.strictEqual(row.media_asset_id, 777);
++ assert.strictEqual(row.media_url, 'https://openvibe.media/chat/emote.png');
++ assert.strictEqual(row.size, 150);
++
++ // Channel list uses media_url.
++ const ch = await h.http('GET', `/api/emotes/channel/${streamer.id}`);
++ assert.strictEqual(ch.status, 200);
++ assert.strictEqual(ch.body.emotes[0].url, 'https://openvibe.media/chat/emote.png');
++ assert.strictEqual(ch.body.emotes[0].source, 'channel');
++
++ // mine: authenticated, count/max.
++ const mine = await h.http('GET', '/api/emotes/mine', { token: streamer.token });
++ assert.strictEqual(mine.status, 200);
++ assert.deepStrictEqual([mine.body.count, mine.body.max], [1, 3]);
++
++ // Clash within the channel.
++ const lie = await upload(streamer.id, streamer.token, { code: 'fakeimg' }, Buffer.from('<html><script>x</script></html>'), 'image/png');
++ assert.strictEqual(lie.status, 400, 'bytes that are not the declared image type are refused');
++ const clash = await upload(streamer.id, streamer.token, { code: 'hype' });
++ assert.strictEqual(clash.status, 409, JSON.stringify(clash.body));
++
++ // Cap: 3 per channel (already 1), the third is refused.
++ assert.strictEqual((await upload(streamer.id, streamer.token, { code: 'two' })).status, 200);
++ assert.strictEqual((await upload(streamer.id, streamer.token, { code: 'three' })).status, 200);
++ const full = await upload(streamer.id, streamer.token, { code: 'four' });
++ assert.strictEqual(full.status, 400, JSON.stringify(full.body));
++ assert.match(full.body.error, /full/);
++
++ // Code validation + bad type.
++ const bad = await upload(streamer.id, streamer.token, { code: 'x' });
++ assert.strictEqual(bad.status, 400);
++ const r2 = await h.http('GET', '/api/emotes/file/whatever.png');
++ assert.strictEqual(r2.status, 404);
++});
++
++t('emote API: rename/resize, delete removes the Media object', async () => {
++ const row = h.db.get('SELECT * FROM emotes WHERE code = ?', ['hype']);
++ let r = await h.http('PATCH', `/api/emotes/${row.id}`, { token: streamer.token, body: { code: 'hype2', size: 300 } });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.deepStrictEqual([r.body.code, r.body.size], ['hype2', 200], 'size clamped to the channel max (200)');
++
++ r = await h.http('DELETE', `/api/emotes/${row.id}`, { token: streamer.token });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(h.db.get('SELECT COUNT(*) AS n FROM emotes WHERE id = ?', [row.id]).n, 0);
++ assert.deepStrictEqual(deletedAssets, [777], 'the Media object is removed');
++});
++
++t('emote API: not-mine is refused, sources round-trip through the Live-context effect', async () => {
++ const row = h.db.get('SELECT id FROM emotes WHERE code = ?', ['two']);
++ const r = await h.http('PATCH', `/api/emotes/${row.id}`, { token: other.token, body: { code: 'nope' } });
++ assert.strictEqual(r.status, 403, r.text);
++
++ const s = await h.http('GET', '/api/emotes/sources', { token: streamer.token });
++ assert.strictEqual(s.status, 200);
++ assert.deepStrictEqual(s.body.sources, { defaults: true, custom: true, ffz: true, bttv: true, '7tv': true });
++ const p = await h.http('PUT', '/api/emotes/sources', { token: streamer.token, body: { ffz: false } });
++ assert.strictEqual(p.status, 200, p.text);
++ assert.strictEqual(p.body.sources.ffz, false);
++ assert.ok(h.live.effects.some((e) => e.name === 'channel-emote-sources'), 'the write reaches Live');
++ const s2 = await h.http('GET', '/api/emotes/sources', { token: streamer.token });
++ assert.strictEqual(s2.body.sources.ffz, false);
++});
++
++// ── Moderators & moderation settings ─────────────────────────
++t('moderators: owner and admin add/remove, a stranger is refused', async () => {
++ asChat('channel_moderators');
++ let r = await h.http('POST', `/api/chat/channels/${channelId}/mods`, { token: stranger.token, body: { username: 'moddy' } });
++ assert.strictEqual(r.status, 403, r.text);
++ r = await h.http('POST', `/api/chat/channels/${channelId}/mods`, { token: streamer.token, body: { username: 'moddy' } });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.ok(r.body.moderators.some((m) => m.user_id === mod.id));
++ r = await h.http('DELETE', `/api/chat/channels/${channelId}/mods/${mod.id}`, { token: admin.token });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.ok(!r.body.moderators.some((m) => m.user_id === mod.id));
++ r = await h.http('POST', `/api/chat/channels/${channelId}/mods`, { token: streamer.token, body: { username: 'nobodyhere' } });
++ assert.strictEqual(r.status, 404, r.text);
++});
++
++t('moderators: list is public to any signed-in reader; moderation settings are mod-only', async () => {
++ const list = await h.http('GET', `/api/chat/channels/${channelId}/mods`, { token: other.token });
++ assert.strictEqual(list.status, 200, list.text);
++ assert.strictEqual(list.body.channel_id, channelId);
++
++ let s = await h.http('GET', `/api/chat/channels/${channelId}/moderation`, { token: other.token });
++ assert.strictEqual(s.status, 403, s.text);
++ s = await h.http('GET', `/api/chat/channels/${channelId}/moderation`, { token: streamer.token });
++ assert.strictEqual(s.status, 200, s.text);
++ assert.strictEqual(s.body.settings.custom_emotes_enabled, 1);
++ const mine = await h.http('GET', '/api/chat/channels/moderation/mine', { token: streamer.token });
++ assert.strictEqual(mine.status, 200, mine.text);
++ assert.ok(mine.body.channels.some((c) => c.id === channelId));
++});
++
++t('moderation settings: owner writes, a mod cannot change policy keys, authority gate first', async () => {
++ // While Live writes the settings, the write refuses.
++ h.db.setTableAuthority('channel_moderation_settings', 'live');
++ let r = await h.http('PUT', `/api/chat/channels/${channelId}/moderation`, { token: streamer.token, body: { slow_mode_seconds: 5 } });
++ assert.strictEqual(r.status, 503, r.text);
++ assert.deepStrictEqual(r.body, { ok: false, error: 'not yet' });
++
++ asChat('channel_moderation_settings');
++ asChat('channel_moderators');
++ h.db.addChannelModerator(channelId, mod.id, streamer.id);
++ r = await h.http('PUT', `/api/chat/channels/${channelId}/moderation`, { token: streamer.token, body: { slow_mode_seconds: 12, ip_approval_mode: 1 } });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.body.settings.slow_mode_seconds, 12);
++ assert.strictEqual(r.body.settings.ip_approval_mode, 1);
++
++ // A mod passing an owner-policy key: ignored, not refused.
++ r = await h.http('PUT', `/api/chat/channels/${channelId}/moderation`, { token: mod.token, body: { slow_mode_seconds: 3, allow_anonymous: 0 } });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.body.settings.slow_mode_seconds, 3);
++ assert.strictEqual(r.body.settings.allow_anonymous, 1, 'owner policy stayed');
++
++ const s = await h.http('GET', `/api/chat/channels/${channelId}/moderation`, { token: mod.token });
++ assert.strictEqual(s.status, 200, s.text);
++
++ const logs = await h.http('GET', `/api/chat/channels/${channelId}/moderation/logs`, { token: streamer.token });
++ assert.strictEqual(logs.status, 200, logs.text);
++ assert.ok(Array.isArray(logs.body.actions));
++ const search = await h.http('GET', `/api/chat/channels/${channelId}/moderation/chat-search?q=hi`, { token: streamer.token });
++ assert.strictEqual(search.status, 200, search.text);
++ assert.ok(Array.isArray(search.body.messages));
++});
++
++// ── Alert-sound op ───────────────────────────────────────────
++t('alert op: Chat resolves the sound from its own row and broadcasts it', async () => {
++ asChat('channel_moderation_settings');
++ const snd = path.join(h.tmp, 'sounds', 'donation.mp3');
++ fs.writeFileSync(snd, 'ID3alert');
++ h.db.setChannelAlertSound(channelId, 'donation', snd, 'audio/mpeg');
++
++ const ws = await h.ws({ ip: '198.51.100.7', stream: streamId });
++ ws.sendJson({ type: 'join', streamId, channelUserId: streamer.id, anonId: null });
++ await ws.next((m) => m.type === 'auth' || m.type === 'connected');
++
++ const calls = (ops) => h.http('POST', '/internal/live/calls', { token: BRIDGE, body: { boot: 'live-boot-alerts', ops: ops.map((o, i) => ({ seq: i + 1, ...o })) } });
++ let r = await calls([{ op: 'playAlertSound', args: [streamer.id, streamId, 'donation'] }]);
++ assert.strictEqual(r.status, 200, r.text);
++ const res = r.body.results[0];
++ assert.strictEqual(res.ok, true, JSON.stringify(res));
++ assert.deepStrictEqual(res.result, { played: true, source: 'donation-alert' });
++ const frame = await ws.next((m) => m.type === 'soundboard-audio');
++ assert.strictEqual(frame.source, 'donation-alert');
++ assert.strictEqual(Buffer.from(frame.audio, 'base64').toString(), 'ID3alert');
++ ws.close();
++
++ // No goal sound set: goal falls back to the donation sound; a channel with none does not play.
++ r = await calls([{ op: 'playAlertSound', args: [streamer.id, streamId, 'goal'] }]);
++ assert.deepStrictEqual(r.body.results[0].result, { played: true, source: 'goal-alert' });
++ r = await calls([{ op: 'playAlertSound', args: [other.id, streamId, 'donation'] }]);
++ assert.deepStrictEqual(r.body.results[0].result, { played: false, source: 'donation-alert' });
++});
++
++t.run(async () => { if (h) await h.close(); });
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex eb0b0d4..756f683 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -117,6 +117,7 @@ t('ratchet: every file that makes an outbound request itself is reviewed', () =>
'server/chat/account-data.js': 'Network internal API (configured), fixed paths: export parts and deletion confirmations',
'server/prefs/network-modules.js': 'Network user modules (configured)',
'server/chat/routes.js': 'GIF providers (fixed hosts)',
++ 'server/chat/emotes-routes.js': 'FFZ / BTTV / 7TV emote providers (fixed hosts, hardcoded URLs)',
'server/chat/soundboard-service.js': '101soundboards (host allowlist; audio via safeLookup)',
'server/chat/tts-engine.js': 'Google / AWS TTS (fixed hosts)',
};