openvibers/openvibe.live Commits

Live: CHAT_AUTHORITY is retired; Chat runs chat with no switch (C-01) (#94)

OP OpenVibers committed 11 Oct 2026 · 5d8224461a · 26 files, +101 −139

This closes compatibility register C-01: `CHAT_AUTHORITY` is retired, and OpenVibe.Chat runs chat with no switch.

**Why now:**
- **Not a rollback:** the switch stopped being one on 2026-10-05. Live's chat server, the read mirror and its twelve chat tables are all gone.
- **A silent failure mode:** anything other than `chat` made chat inert. Every push was dropped and every read came back empty. That is the same shape as the 10-10 go-live outage (C-85): a two-sided switch whose "off" side quietly does nothing.

**What changes:**
- **Delivery and reads:**
  - `server/chat/chat-authority.js` is deleted; `CHAT_URL` lives in `chat-client.js`.
  - `chat-delivery.js` loses `ingress()` and its drop branches; `chat-reads.js` asks Chat whenever it can.
  - Callers that branched on the mode always take the Chat path: the tips TTS line, the media-queue frame, the recap announcement and the user cache hints.
- **Effects:** `/internal/chat-effects/*` no longer answers 409 outside chat mode. The gate is the `live.chat_effects.write` capability, which a rehearsal Chat does not hold.
- **When Live cannot ask Chat:** with no service credentials, an empty `OV_CHAT_INTERNAL_URL`, or `LIVE_DRILL`, `chat-client.js` drops each call and logs it once, and reads answer empty. `chat-client.available()` names this, and the presence poll starts only when it is true.
- **Tests:**
  - The preload sets `OV_CHAT_INTERNAL_URL` to empty unless a test sets it, so no test ever reaches a real Chat (the old unset-mode behaviour). Tests that exercise Chat already point it at their stub.
  - `chat-bridge-removed` now also fails on any `process.env.CHAT_AUTHORITY` or `chat-authority` under `server/`.
  - `tips-delivery` asserts the `tts` line sent to Chat (it used to exercise the dead synthesis branch).
  - `chat-ingress` checks that a leftover empty `CHAT_AUTHORITY` still delivers.
- **Docs:** `.env.example`, `docs/chat-system.md`, and the history row of the API docs page; that row no longer offers `?scope=stream`, which Chat#70 removed (C-06).

**Production:** `live.env` keeps `CHAT_AUTHORITY=chat`, which nothing reads after this deploy; it is removed from the env afterwards. `OV_CHAT_INTERNAL_URL` is set there.

`OV_ALLOW_SUITE=1 ov test --all`: 164 passed.

Changes

ddiff --git a/.env.example b/.env.example
iindex c2ef9a73..13a54b71 100644
---- a/.env.example
++++ b/.env.example
@@@ -156,14 +156,12 @@ OV_COMMUNITY_URL=https://openvibe.community
# and community.comment.moderate on openvibe.community)
OV_COMMUNITY_INTERNAL_URL=http://127.0.0.1:4200
--# Chat runs in OpenVibe.Chat when this is "chat" (roadmap W6; server/chat/chat-authority.js): Live
--# stops serving /ws/chat and /api/chat|dm|tts|sounds (nginx sends them to Chat), delivers its own
--# chat producers (AI viewer lines, donations and alerts, relay and RobotStreamer chat, cards, moderation,
--# cache hints, presence) through Chat's typed ingress /internal/chat/* (server/chat/chat-delivery.js; Live's
--# principal needs chat.message.send, chat.event.publish, chat.moderation.write, chat.cache.invalidate and
--# chat.presence.read for audience openvibe.chat) and keeps its chat tables as Chat's read mirror.
--# Unset = Live runs chat.
--#CHAT_AUTHORITY=chat
++# OpenVibe.Chat runs chat (/ws/chat and /api/chat|dm|tts|sounds; nginx sends them there). Live delivers its
++# own chat producers (AI viewer lines, donations and alerts, relay and RobotStreamer chat, cards, moderation,
++# cache hints, presence) through Chat's typed ingress /internal/chat/* and reads chat stats, queues and history
++# from Chat (server/chat/chat-delivery.js, chat-reads.js; Live's principal needs chat.message.send,
++# chat.event.publish, chat.moderation.write, chat.cache.invalidate, chat.presence.read and the chat.*.read
++# capabilities for audience openvibe.chat). There is no switch: without OV_OAUTH_CLIENT_SECRET chat is inert.
OV_CHAT_INTERNAL_URL=http://127.0.0.1:4400
# Bot panel embed (server/bot/): "1" lets a channel owner bind the channel to an OpenVibe.Bot robot
# (PUT /api/streams/channel/:username/bot) and adds bot_embed { enabled, robot_id, url } to the channel
ddiff --git a/docs/chat-system.md b/docs/chat-system.md
iindex 0b2cd513..8226ca67 100644
---- a/docs/chat-system.md
++++ b/docs/chat-system.md
@@@ -11,9 +11,9 @@ The OpenVibe.Live chat system provides real-time messaging, moderation, and exte
### OpenVibe.Chat (roadmap Wave 6; live since 2026-09-23)
Chat runs in [OpenVibe.Chat](https://github.com/OpenVibers/OpenVibe.Chat) (127.0.0.1:4400) with the
--same WebSocket protocol and REST paths. Live runs no chat server in any mode (`CHAT_AUTHORITY=chat`
--in production, `server/chat/chat-authority.js`); the local `ChatServer` and its `/ws/chat` upgrade
--path are deleted.
++same WebSocket protocol and REST paths. Live runs no chat server, and there is no switch (the
++`CHAT_AUTHORITY` flag was retired on 2026-10-11); the local `ChatServer` and its `/ws/chat` upgrade
++path are deleted. Without Live's service credentials (development, tests) chat is inert.
- nginx sends `/ws/chat`, `/api/chat/`, `/api/dm/`, `/api/tts/` and `/api/sounds` to Chat; Live mounts
none of them, and a `/ws/chat` upgrade that still lands on Live is destroyed.
@@@ -43,8 +43,7 @@ path are deleted.
- Chat reads Live data and asks for side effects on `/internal/chat-context/*` and
`/internal/chat-effects/*` (`server/chat/live-context-routes.js`), with Network service tokens
(`live.chat_context.read`, `live.chat_effects.write`,
-- `server/net/service-guard.js`). Effects re-check the acting moderator/owner/admin and are refused
-- unless `CHAT_AUTHORITY=chat`.
++ `server/net/service-guard.js`). Effects re-check the acting moderator/owner/admin.
- Live reads chat stats, queues and history from Chat's internal read API —
`POST /internal/chat/stats` (`site`, `user`, `stream`, `channel-top` and `site-daily`),
`GET /internal/chat/messages`, `/timeline`, `/first-chat`, `/sounds`, `/sounds/by-command`,
ddiff --git a/public/fragments/documentation.html b/public/fragments/documentation.html
iindex 522dcd63..c8ed8238 100644
---- a/public/fragments/documentation.html
++++ b/public/fragments/documentation.html
@@@ -1247,7 +1247,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<thead><tr><th>Method</th><th>Path</th><th>Auth</th><th>Notes</th></tr></thead>
<tbody>
<tr><td>POST</td><td>/api/chat/send</td><td>Yes</td><td>Post a message to global chat (bots; API tokens need the <code>chat</code> scope)</td></tr>
--<tr><td>GET</td><td>/api/chat/:streamId/history</td><td>Opt</td><td>Per-streamer history (all slots + offline unless <code>?scope=stream</code>)</td></tr>
++<tr><td>GET</td><td>/api/chat/:streamId/history</td><td>Opt</td><td>Per-streamer history (all of the streamer's slots, plus offline messages)</td></tr>
<tr><td>GET</td><td>/api/chat/global/history</td><td>Opt</td><td>Global chat history</td></tr>
<tr><td>GET</td><td>/api/chat/:streamId/replay</td><td>Opt</td><td>Chat replay for VOD/clip sync (<code>?from,to</code>)</td></tr>
<tr><td>GET</td><td>/api/chat/anon/:anonId &middot; /relay-user/:platform/:username</td><td>Opt</td><td>Anon / relay chatter meta (+ <code>/logs</code>, auth)</td></tr>
ddiff --git a/server/auth/network-events.js b/server/auth/network-events.js
iindex fe1110bf..eca135b5 100644
---- a/server/auth/network-events.js
++++ b/server/auth/network-events.js
@@@ -26,7 +26,7 @@ const stats = { received: 0, revoked: 0, unchanged: 0, profiles: 0, ignored: 0,
const notifyChat = async (userId) => {
try {
const delivery = require('../chat/chat-delivery');
-- if (delivery.ingress()) await delivery.invalidate({ user: Number(userId) });
++ await delivery.invalidate({ user: Number(userId) });
} catch { /* non-critical */ }
};
ddiff --git a/server/chat/chat-authority.js b/server/chat/chat-authority.js
ddeleted file mode 100644
iindex 46254be3..00000000
---- a/server/chat/chat-authority.js
++++ /dev/null
@@@ -1,27 +0,0 @@
--'use strict';
--/**
-- * Who runs chat (roadmap Wave 6).
-- *
-- * CHAT_AUTHORITY=chat: OpenVibe.Chat (127.0.0.1:4400) serves /ws/chat and the chat REST routes
-- * (nginx routes them there) and owns the chat tables. Live then
-- * - does not start its chat WebSocket server or mount /api/chat, /api/dm, /api/tts, /api/sounds;
-- * - delivers every chat call its other modules make through Chat's typed ingress
-- * (server/chat/chat-delivery.js → chat-client.js), which also carries the push names, the
-- * presence reads and the address/anon helpers Live's modules used on its old chat server — Live
-- * runs no chat server in any mode;
-- * - reads chat stats, queues and history from Chat's internal read API
-- * (server/chat/chat-reads.js → chat-client.js); the read mirror Chat used to write back was
-- * retired on 2026-10-05, Live keeps no copy of Chat's tables, and its twelve are dropped by boot
-- * migration 007_drop_chat_tables;
-- * - answers Chat's reads and side effects on /internal/chat-context/* and /internal/chat-effects/*.
-- * Anything else (default): Live runs no chat server any more (chat-server.js is gone), so chat is inert: every push
-- * is dropped and logged once by chat-delivery.js. Unsetting CHAT_AUTHORITY is therefore no rollback lever.
-- */
--
--const CHAT_URL = (process.env.OV_CHAT_INTERNAL_URL || 'http://127.0.0.1:4400').replace(/\/+$/, '');
--
--function isRemote() {
-- return process.env.CHAT_AUTHORITY === 'chat';
--}
--
--module.exports = { isRemote, CHAT_URL };
ddiff --git a/server/chat/chat-client.js b/server/chat/chat-client.js
iindex ca8a9c76..b648fb1a 100644
---- a/server/chat/chat-client.js
++++ b/server/chat/chat-client.js
@@@ -35,7 +35,10 @@
*/
const crypto = require('crypto');
const principal = require('../net/network-principal');
--const { CHAT_URL } = require('./chat-authority');
++
++/** OpenVibe.Chat's loopback address (default 127.0.0.1:4400): the ingress, the reads and Chat's internal APIs.
++ * Set empty, there is no Chat to ask (the test preload does this): every call is dropped and logged once. */
++const CHAT_URL = (process.env.OV_CHAT_INTERNAL_URL ?? 'http://127.0.0.1:4400').replace(/\/+$/, '');
const AUDIENCE = 'openvibe.chat';
const TIMEOUT_MS = 5000;
@@@ -49,6 +52,8 @@ let lastLog = null;
let retryMs = RETRY_MS;
function drill() { try { return require('../drill').enabled; } catch { return false; } }
++/** Whether Live can ask Chat at all: a Chat address, Live's service credentials, and not a restore drill. */
++function available() { return !!CHAT_URL && principal.configured() && !drill(); }
function count(family, result) {
const f = stats.byFamily[family] || (stats.byFamily[family] = { sent: 0, refused: 0, failed: 0, dropped: 0 });
@@@ -88,6 +93,7 @@ const sleep = (ms) => new Promise((resolve) => { const t = setTimeout(resolve, m
async function send(family, body, path = `/internal/chat/${family}`) {
if (drill()) return null;
if (!principal.configured()) { count(family, 'dropped'); note(`${family}: not configured (OV_OAUTH_CLIENT_SECRET unset); dropped`); return null; }
++ if (!CHAT_URL) { count(family, 'dropped'); note(`${family}: no Chat address (OV_CHAT_INTERNAL_URL is empty); dropped`); return null; }
const payload = { ...body, key: body.key || key(family) };
if (waiting >= MAX_WAITING) { count(family, 'dropped'); note(`${family}: ${waiting} operations already waiting for Chat; dropped`); return null; }
waiting++;
@@@ -120,7 +126,7 @@ async function send(family, body, path = `/internal/chat/${family}`) {
/** Chat's presence snapshot ({ total, streams, slow_mode, users, anons }), or null. */
async function presence() {
-- if (drill() || !principal.configured()) return null;
++ if (!available()) return null;
try {
const { status, data } = await request('GET', '/internal/chat/presence');
if (status === 200 && data) return data;
@@@ -148,7 +154,7 @@ function qs(params) {
/** GET one Chat read; its JSON body or null. Ordinary failures are logged and answered null. */
async function read(name, path) {
-- if (drill() || !principal.configured()) return null;
++ if (!available()) return null;
try {
const { status, data } = await request('GET', path);
if (status === 200 && data && data.ok !== false) return data;
@@@ -159,7 +165,7 @@ async function read(name, path) {
/** POST /internal/chat/stats — message/chatter counts, or the top chatters of a room. */
async function readStats(body) {
-- if (drill() || !principal.configured()) return null;
++ if (!available()) return null;
try {
const { status, data } = await request('POST', '/internal/chat/stats', body || {});
if (status === 200 && data && data.ok !== false) return data;
@@@ -184,7 +190,7 @@ const readDmBlockState = async (params) => await read('dm-block-state', `/intern
* falls back to Live's table rather than silencing every !sound.
*/
async function readSoundByCommand(params) {
-- if (drill() || !principal.configured()) return null;
++ if (!available()) return null;
try {
const { status, data } = await request('GET', `/internal/chat/sounds/by-command${qs(params)}`);
if (status === 200 && data && data.ok !== false && data.sound) return data;
@@@ -195,7 +201,7 @@ async function readSoundByCommand(params) {
}
/** Record where Live's asset sync uploaded a channel sound (idempotent; Chat applies the same write once). */
async function soundAsset(body) {
-- if (drill() || !principal.configured()) return null;
++ if (!available()) return null;
try {
// Chat's /sounds/asset takes exactly { id, media_url, media_asset_id } — no idempotency key.
const { status, data } = await request('POST', '/internal/chat/sounds/asset', body);
@@@ -206,6 +212,8 @@ async function soundAsset(body) {
}
module.exports = {
++ CHAT_URL,
++ available,
AUDIENCE,
key,
stats,
ddiff --git a/server/chat/chat-delivery.js b/server/chat/chat-delivery.js
iindex e65b54cd..69fae79f 100644
---- a/server/chat/chat-delivery.js
++++ b/server/chat/chat-delivery.js
@@@ -2,20 +2,20 @@
/**
* The one seam Live's chat producers call (T3 J2; Live's own ChatServer is deleted).
*
-- * CHAT_AUTHORITY=chat: OpenVibe.Chat's typed ingress (chat-client.js) — Chat persists, broadcasts, mirrors,
-- * speaks and moderates in one call per operation. This is the only path to Chat: the old ordered-calls bridge
-- * and its outbox are gone, and Live no longer runs a chat server in any mode. This module also carries the
-- * ChatServer surface Live's remaining modules still use.
++ * OpenVibe.Chat runs chat; its typed ingress (chat-client.js) persists, broadcasts, speaks and moderates in one
++ * call per operation. This is the only path to Chat: the old ordered-calls bridge and its outbox are gone, Live
++ * runs no chat server, and there is no mode switch (CHAT_AUTHORITY is retired, compatibility register C-01).
++ * When Live cannot ask Chat (no service credentials or an empty OV_CHAT_INTERNAL_URL, as in development and
++ * tests, or LIVE_DRILL), chat-client.js drops each call and logs it once. This module also carries the ChatServer surface Live's remaining modules still use.
*
* A Chat 4xx/5xx is logged and counted by chat-client.js and never thrown into the caller.
*
-- * ingress() which path is active (CHAT_AUTHORITY=chat)
-- * message(body) ingress only: a chat line (Chat's /messages body) → Promise<real id | null>
++ * message(body) a chat line (Chat's /messages body) → Promise<real id | null>
* event(target, frame, opts) a transient frame; target { kind: stream|channel|global|all|user | Chat's own kinds }
-- * moderate(action, fields) ingress only: Chat's /moderation actions → Promise<Chat's answer | null>
++ * moderate(action, fields) Chat's /moderation actions → Promise<Chat's answer | null>
* logModeration(entry) a moderation-log row (Chat's moderation_actions shape)
* disconnect({ userId, ip, streamId })
-- * invalidate(hint, legacy) a cache hint ({ user, user_data, approvals, bans, channel })
++ * invalidate(hint) a cache hint ({ user, user_data, approvals, bans, channel })
* after(value, fn) fn(value) now for a plain value, after it resolves for a promise
*
* The pushes Live's modules used to make on the chat server keep their names here — broadcastToStream,
@@@ -31,11 +31,8 @@
* which send Chat a cache hint instead of waiting for its cache to expire.
*/
const db = require('../db/database');
--const chatAuthority = require('./chat-authority');
const client = require('./chat-client');
--function ingress() { return chatAuthority.isRemote(); }
--
function defined(o) {
const out = {};
for (const [k, v] of Object.entries(o || {})) if (v !== undefined) out[k] = v;
@@@ -67,7 +64,6 @@ function dropped(name, why = 'has no Chat ingress target and was dropped') {
}
async function event(target, frame, { key } = {}) {
-- if (!ingress()) { dropped(`event(${target.kind})`, 'dropped — Live runs no chat server (CHAT_AUTHORITY is not chat)'); return undefined; }
const t = { kind: target.kind };
if (target.id != null) t.id = Number(target.id);
return await client.event({ key: key ? client.key('events', key) : undefined, target: t, frame: defined(frame) });
@@@ -88,15 +84,12 @@ async function logModeration(entry) {
}
async function disconnect({ userId, ip, streamId } = {}) {
-- if (!ingress()) { dropped('disconnectUser', 'dropped — Live runs no chat server (CHAT_AUTHORITY is not chat)'); return undefined; }
return await moderate('disconnect', { user_id: userId || undefined, ip: ip || undefined, stream_id: streamId || undefined });
}
const disconnectUser = disconnect;
--/** legacy: what to run when Live is the chat authority (cache hints had no single local call). */
--async function invalidate(hint, legacy) {
-- if (ingress()) return await client.invalidate(defined(hint));
-- return legacy ? legacy() : undefined;
++async function invalidate(hint) {
++ return await client.invalidate(defined(hint));
}
function after(value, fn) {
@@@ -159,15 +152,16 @@ async function _pollPresence() {
} catch { /* keep the last snapshot */ }
}
--/** Start the presence poll (and the cache-hint observers). Called once at startup; no-op outside chat mode. */
++/** Start the presence poll (and the cache-hint observers). Called once at startup; off when Live cannot ask Chat
++ * (LIVE_DRILL included). */
async function init() {
-- if (!ingress()) return null;
++ if (!client.available()) return null;
if (!_dbObserved) { _dbObserved = true; _observeDb(); }
if (_presenceTimer) return null;
_pollPresence().catch((e) => console.warn('[Chat] presence:', e.message));
_presenceTimer = setInterval(() => _pollPresence().catch((e) => console.warn('[Chat] presence:', e.message)), PRESENCE_MS);
if (_presenceTimer.unref) _presenceTimer.unref();
-- console.log('[Chat] CHAT_AUTHORITY=chat — chat runs in OpenVibe.Chat; Live delivers through its ingress');
++ console.log('[Chat] chat runs in OpenVibe.Chat; Live delivers through its ingress');
return null;
}
@@@ -308,8 +302,7 @@ async function resolveAnon(ip) {
return { anon_number: num, first_seen: await db.getAnonFirstSeen(key) };
}
--module.exports = {
-- ingress, message, event, moderate, logModeration, disconnect, disconnectUser, invalidate, after, client,
++module.exports = { message, event, moderate, logModeration, disconnect, disconnectUser, invalidate, after, client,
broadcastToStream, broadcastToChannelRoom, broadcastGlobal, broadcastAll, sendDm, sendUserUpdate,
forwardToGlobal, forwardToStreamerRooms, triggerChannelSound, synthesizeAndBroadcastTTS,
init, close, slowModeByStream, getTotalConnections, getStreamViewerCount, getConnectedUserIp, findClientByAnonId,
ddiff --git a/server/chat/chat-reads.js b/server/chat/chat-reads.js
iindex 1123d715..8b381e4f 100644
---- a/server/chat/chat-reads.js
++++ b/server/chat/chat-reads.js
@@@ -6,11 +6,11 @@
* recaps, AI context, admin console, mod queues and the call-invite gate read them through Chat's
* internal read API (server/chat/chat-client.js). Live keeps no copy of any of them since 2026-10-05
* (the read mirror was retired on both sides; the tables themselves are dropped at boot by
-- * migration 007_drop_chat_tables), so outside chat mode every one of these answers null / empty.
++ * migration 007_drop_chat_tables).
*
-- * Mode-aware: when CHAT_AUTHORITY=chat, every answer comes from Chat; otherwise (dev, drills) the
-- * same call answers null / empty — the caller never has to know which. Unsetting CHAT_AUTHORITY is
-- * not a rollback lever: Live runs no chat server any more either way.
++ * Every answer comes from Chat. When Live cannot ask Chat (no service credentials or an empty
++ * OV_CHAT_INTERNAL_URL, as in development and tests, or LIVE_DRILL), the same call answers null /
++ * empty: the caller never has to know which. (The CHAT_AUTHORITY switch is retired: compatibility register C-01.)
*
* Caching: an answer is fresh for CACHE_TTL_MS. An async read past that asks Chat again; a peek
* (for a caller that cannot await) answers the last good value while that refresh runs, so a peek
@@@ -26,7 +26,6 @@
* Callers that can await use the async function; the few that cannot (the AI persona prompt, the
* home-stats snapshot, the TTS engine) use the matching peek*().
*/
--const { isRemote } = require('./chat-authority');
const client = require('./chat-client');
const CACHE_TTL_MS = 15_000; // how long an answer is used without asking Chat again
@@@ -38,8 +37,7 @@ const inflight = new Map(); // key -> Promise
let cacheMax = 2000; // test hook; the cap remember()/rememberFailure() evict down to
let lastLog = null;
--function drill() { try { return require('../drill').enabled; } catch { return false; } }
--function remote() { return isRemote() && !drill(); }
++function remote() { return client.available(); }
function local() { return require('../db/database'); }
/** Run a Live-table read for a synchronous caller, answering `dflt` if it throws. Still used for
* the non-chat home metrics (users, follows, …), which Live owns. */
ddiff --git a/server/chat/insight-client.js b/server/chat/insight-client.js
iindex b5daa048..70395afe 100644
---- a/server/chat/insight-client.js
++++ b/server/chat/insight-client.js
@@@ -15,7 +15,7 @@
* outage costs the insight text and never a request. Off under LIVE_DRILL (a restore drill calls no
* other service).
*/
--const { CHAT_URL } = require('./chat-authority');
++const { CHAT_URL } = require('./chat-client');
const CACHE_TTL_MS = 60_000;
const TIMEOUT_MS = 4000;
ddiff --git a/server/chat/live-context-routes.js b/server/chat/live-context-routes.js
iindex 557ba6dc..3c97b62c 100644
---- a/server/chat/live-context-routes.js
++++ b/server/chat/live-context-routes.js
@@@ -22,7 +22,7 @@
* GET /anon/:num, /anon-first-seen?ip → { first_seen }
* GET /tts-audio/:file → a clip from the arena voice cache
*
-- * /internal/chat-effects/* capability live.chat_effects.write — side effects (CHAT_AUTHORITY=chat only)
++ * /internal/chat-effects/* capability live.chat_effects.write — side effects
* anon, ip-log, viewer-counts, viewer-snapshots, user-color, ban, approve-ip, channel-settings,
* alert-sound, ensure-channel, site-settings, chat-message, ai/mod-command, arena-command,
* media-queue, hardware, paste, translate, notify/dm, notify/dm-read, notify/call-invite, asset-sync
@@@ -37,7 +37,6 @@ const db = require('../db/database');
const config = require('../config');
const permissions = require('../auth/permissions');
const { guard } = require('../net/service-guard');
--const { isRemote } = require('./chat-authority');
const contextRouter = express.Router();
const effectsRouter = express.Router();
@@@ -252,13 +251,9 @@ contextRouter.get('/tts-audio/:file', (req, res) => {
// ── Effects ────────────────────────────────────────────────────
++// Only a token carrying live.chat_effects.write gets in: a rehearsal Chat without that grant can never
++// award coins, post to AI viewers or change Live's rows.
effectsRouter.use(guard('live.chat_effects.write'));
--// Live only takes Chat's writes while Chat is the authority — a rehearsal Chat pointed at
--// production Live can never award coins, post to AI viewers or overwrite chat tables.
--effectsRouter.use((req, res, next) => {
-- if (!isRemote()) return fail(res, 409, 'Live runs chat itself (CHAT_AUTHORITY is not "chat")');
-- next();
--});
const chatDelivery = () => require('./chat-delivery');
const actorOf = async (id) => (id ? await db.getUserById(id) : null);
ddiff --git a/server/chat/moderation-client.js b/server/chat/moderation-client.js
iindex c186e499..1b06093b 100644
---- a/server/chat/moderation-client.js
++++ b/server/chat/moderation-client.js
@@@ -20,7 +20,7 @@
* Off under LIVE_DRILL (a restore drill calls no other service) and without OV_OAUTH_CLIENT_SECRET.
*/
const { createServiceTokenClient } = require('openvibe-sdk/auth');
--const { CHAT_URL } = require('./chat-authority');
++const { CHAT_URL } = require('./chat-client');
const AUDIENCE = 'openvibe.chat';
const CACHE_TTL_MS = 30_000;
ddiff --git a/server/db/database.js b/server/db/database.js
iindex 6faf352a..b2d6c72c 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -1442,7 +1442,7 @@ async function homeSeriesLocal(metric, days = 30) {
}
/**
-- * The daily series behind a hero stat. With CHAT_AUTHORITY=chat the two chat metrics (`messages`,
++ * The daily series behind a hero stat. The two chat metrics (`messages`,
* `active`) answer from Chat's site-wide per-day read (chat-reads.homeSeriesPeek — a cached
* stale-while-revalidate peek that falls back to Live's own tables when Chat is unreachable);
* every other metric is Live's own.
ddiff --git a/server/index.js b/server/index.js
iindex e1d95ea7..ced5287a 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -740,7 +740,7 @@ app.use('/api/streams', streamRoutes);
const liveEvents = require('./streaming/live-events');
app.get('/api/live-events', (req, res) => liveEvents.subscribe(req, res));
// OpenVibe.Chat owns the chat REST routes (/api/chat/, /api/dm/, /api/tts/, /api/sounds); nginx
--// sends them there, so Live mounts none of them and keeps no CHAT_AUTHORITY rollback.
++// sends them there, so Live mounts none of them.
app.use('/api/funds', monetizationRoutes);
app.use('/api/coins', coinsRoutes);
app.use('/api/payments', require('./monetization/payments-routes'));
ddiff --git a/server/internal/routes.js b/server/internal/routes.js
iindex 6a1350e9..c85be390 100644
---- a/server/internal/routes.js
++++ b/server/internal/routes.js
@@@ -12,11 +12,11 @@ const { bindSlot, BindError } = require('../openre/bind');
// Service-to-service only, loopback only (nothing that came through nginx): each route checks the one capability it
// performs on a Network service token (server/net/service-guard.js); nothing else gets in (X-Internal-Key: plan T2).
--// CHAT_AUTHORITY=chat: OpenVibe.Chat caches users; a role or avatar pushed here reaches it at once.
++// OpenVibe.Chat caches users; a role or avatar pushed here reaches it at once.
async function notifyChat(userId) {
try {
const delivery = require('../chat/chat-delivery');
-- if (delivery.ingress()) await delivery.invalidate({ user: Number(userId) });
++ await delivery.invalidate({ user: Number(userId) });
} catch { /* non-critical */ }
}
ddiff --git a/server/media/media-queue.js b/server/media/media-queue.js
iindex 8e5763ab..86632d80 100644
---- a/server/media/media-queue.js
++++ b/server/media/media-queue.js
@@@ -899,11 +899,7 @@ class MediaQueue {
try {
const delivery = require('../chat/chat-delivery');
// Chat's ingress fans a media frame out to the owner's live streams itself.
-- if (delivery.ingress()) { await delivery.event({ kind: 'owner-streams', id: streamerId }, payload); return; }
-- const streams = await db.getLiveStreamsByUserId(streamerId) || [];
-- for (const stream of streams) {
-- await delivery.broadcastToStream(stream.id, payload);
-- }
++ await delivery.event({ kind: 'owner-streams', id: streamerId }, payload);
} catch {
// optional
}
ddiff --git a/server/monetization/alerts.js b/server/monetization/alerts.js
iindex 329293bb..aa4a3f1b 100644
---- a/server/monetization/alerts.js
++++ b/server/monetization/alerts.js
@@@ -2,7 +2,7 @@
* alerts.js — streamer alert sounds for donations / donation-goal-reached events.
*
* The sound mapping lives on channel_moderation_settings, which OpenVibe.Chat owns and reads locally
-- * (roadmap T3), so Live no longer reads the file or broadcasts base64 itself. With CHAT_AUTHORITY=chat it
++ * (roadmap T3), so Live no longer reads the file or broadcasts base64 itself. It
* asks Chat to play the alert with an `alert` event on Chat's typed ingress (server/chat/chat-delivery.js),
* and Chat resolves the sound from its own settings row, reads the shared sounds file and broadcasts the
* `soundboard-audio` message to the channel room. A goal-reached event is `kind: 'goal'` (Chat falls back
ddiff --git a/server/recap/recap.js b/server/recap/recap.js
iindex de3f7af3..4f3f35cf 100644
---- a/server/recap/recap.js
++++ b/server/recap/recap.js
@@@ -179,8 +179,7 @@ async function announce(recap) {
const delivery = require('../chat/chat-delivery');
const g = recap.write.grade;
const frame = { type: 'system', message: `📋 After-show report for "${recap.stream.title}" is in — grade ${g}: ${recap.write.headline}. Read it: /recap/${recap.stream.id}` };
-- if (delivery.ingress()) await delivery.event({ kind: 'channel', id: recap.streamer.id }, frame, { key: `recap:${recap.stream.id}` });
-- else await delivery.broadcastToChannelRoom(recap.streamer.id, recap.stream.id, frame);
++ await delivery.event({ kind: 'channel', id: recap.streamer.id }, frame, { key: `recap:${recap.stream.id}` });
await db.run('UPDATE stream_recaps SET announced_at = ov_now() WHERE stream_id = ?', [recap.stream.id]);
return true;
} catch (e) { console.warn('[Recap] announce:', e.message); return false; }
ddiff --git a/server/streaming/calls-authority.js b/server/streaming/calls-authority.js
iindex 4443c7ca..90f102b9 100644
---- a/server/streaming/calls-authority.js
++++ b/server/streaming/calls-authority.js
@@@ -11,7 +11,7 @@
* superseded it (a stream that ended while its go-live was being retried is not given a channel afterwards); a 4xx
* is logged and dropped.
*/
--const { CHAT_URL } = require('../chat/chat-authority');
++const { CHAT_URL } = require('../chat/chat-client');
const AUDIENCE = 'openvibe.chat';
const RETRY_MS = [1000, 5000, 15000];
ddiff --git a/server/tips/delivery-routes.js b/server/tips/delivery-routes.js
iindex 96016d31..3763e711 100644
---- a/server/tips/delivery-routes.js
++++ b/server/tips/delivery-routes.js
@@@ -17,7 +17,7 @@
*
* chat_line | paid_message donation event to the channel room + global, saved as a 'donation'
* chat message (metadata.source 'tips'), the donation alert sound
-- * tts synthesizeAndBroadcastTTS on the live stream (or the offline channel room)
++ * tts a `tts` line in the channel, which Chat reads aloud
* media_request into the media queue at cost 0 — Billing charged it, Tips holds the record
*/
const express = require('express');
@@@ -122,16 +122,12 @@ router.post('/deliveries', guard('live.tips_delivery.write'), express.json({ lim
if (b.effect === 'tts') {
const text = String((b.tts && b.tts.text) || '').slice(0, 1200);
if (!text) return res.status(422).json({ error: 'no text' });
-- if (delivery.ingress()) {
-- // Chat's ingress speaks a persisted line: a `tts` line in the channel, read aloud under the same keys.
-- const id = await delivery.message({
-- stream_id: streamId, channel_user_id: userId, username: name, message: text, message_type: 'tts', source_platform: 'tips',
-- tts: { identity_key: `tips:${i.id}`, key: `tips-${i.id}` }, key: `tips:${key}`,
-- });
-- return res.json(await remember(key, { ok: true, ref: { stream_id: streamId, chat_message_id: id } }));
-- }
-- await delivery.synthesizeAndBroadcastTTS(streamId, name, text, null, 'tips', `tips:${i.id}`, userId, `tips-${i.id}`);
-- return res.json(await remember(key, { ok: true, ref: { stream_id: streamId } }));
++ // Chat's ingress speaks a persisted line: a `tts` line in the channel, read aloud under the same keys.
++ const id = await delivery.message({
++ stream_id: streamId, channel_user_id: userId, username: name, message: text, message_type: 'tts', source_platform: 'tips',
++ tts: { identity_key: `tips:${i.id}`, key: `tips-${i.id}` }, key: `tips:${key}`,
++ });
++ return res.json(await remember(key, { ok: true, ref: { stream_id: streamId, chat_message_id: id } }));
}
if (b.effect === 'media_request') {
const mq = require('../media/media-queue');
ddiff --git a/test/alerts-bridge.test.js b/test/alerts-bridge.test.js
iindex 9d882126..143e80c2 100644
---- a/test/alerts-bridge.test.js
++++ b/test/alerts-bridge.test.js
@@@ -31,8 +31,8 @@ assert.deepStrictEqual(calls.map(({ target, frame }) => [target.kind, target.id,
['channel', 7, 7, null, 'goal'],
], 'unknown kinds fall back to the donation sound, and the alert targets the channel owner');
--// Outside chat mode the seam drops the event (logged once), never throws into the caller.
--delete process.env.CHAT_AUTHORITY;
++// With no Chat to ask (the preload leaves OV_CHAT_INTERNAL_URL empty) the seam drops the event (logged once),
++// never throws into the caller.
assert.doesNotThrow(() => alerts.playAlertSound(7, 3, 'donation'));
console.log('alerts event: all checks passed');
ddiff --git a/test/chat-bridge-removed.test.js b/test/chat-bridge-removed.test.js
iindex 29835005..70aa0116 100644
---- a/test/chat-bridge-removed.test.js
++++ b/test/chat-bridge-removed.test.js
@@@ -23,7 +23,8 @@ const offenders = [];
if (/chat_bridge_outbox/i.test(src)) offenders.push(`${rel}: chat_bridge_outbox`);
if (/chat-remote/.test(src)) offenders.push(`${rel}: chat-remote`);
if (/\/internal\/live\/(calls|presence)/.test(src)) offenders.push(`${rel}: the old bridge endpoints`);
-- if (/LIVE_CHAT_INGRESS/.test(src)) offenders.push(`${rel}: LIVE_CHAT_INGRESS (retired: CHAT_AUTHORITY=chat is the ingress)`);
++ if (/LIVE_CHAT_INGRESS/.test(src)) offenders.push(`${rel}: LIVE_CHAT_INGRESS (retired: Chat's ingress is the only path)`);
++ if (/process\.env\.CHAT_AUTHORITY|chat-authority/.test(src)) offenders.push(`${rel}: CHAT_AUTHORITY (retired, compatibility register C-01: Chat runs chat, with no switch)`);
}
})(path.join(ROOT, 'server'));
assert.deepStrictEqual(offenders, [], `server/ still uses the old chat bridge:\n${offenders.join('\n')}`);
@@@ -37,6 +38,6 @@ assert.ok(!/\bCREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?chat_bridge_outbox\b/i.t
const db = require('../server/db/database');
await db.initDb();
assert.ok(!await db.getDb().prepare("SELECT 1 FROM information_schema.tables WHERE table_schema = current_schema() AND table_name = 'chat_bridge_outbox'").get(), 'chat_bridge_outbox is not in the migrated database');
-- console.log('chat bridge removed: no outbox, bridge or flag under server/; no migration or table for the outbox — all checks passed');
++ console.log('chat bridge removed: no outbox, bridge, CHAT_AUTHORITY or flag under server/; no migration or table for the outbox — all checks passed');
process.exit(0);
})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/chat-context.test.js b/test/chat-context.test.js
iindex 0a7f29eb..d922e5ca 100644
---- a/test/chat-context.test.js
++++ b/test/chat-context.test.js
@@@ -2,7 +2,7 @@
// Chat moved to OpenVibe.Chat (roadmap Wave 6). What Live answers it on /internal/chat-context/*
// and /internal/chat-effects/* (service tokens, capabilities, re-checked moderators), and the chat
--// server Live's own modules get with CHAT_AUTHORITY=chat (presence reads, cache hints and pushes
++// server Live's own modules get (presence reads, cache hints and pushes
// over Chat's typed ingress; no bridge, no outbox). Against stub Network and Chat servers.
const assert = require('assert');
@@@ -23,7 +23,6 @@ process.env.OV_NETWORK_URL = 'https://openvibe.network';
process.env.OV_OAUTH_CLIENT_ID = 'live';
process.env.OV_OAUTH_CLIENT_SECRET = 'live-secret';
process.env.SOUNDS_PATH = path.join(tmp, 'sounds');
--process.env.CHAT_AUTHORITY = 'chat';
const quiet = console.log;
console.log = () => {};
console.warn = () => {};
@@@ -369,10 +368,8 @@ const waitFor = async (pred, what, ms = 3000) => {
assert.strictEqual(settings.tts_enabled, await db.getSetting('tts_enabled'), 'typed like Live\'s getSetting');
assert.ok(!('stripe_secret_key' in settings), 'only chat settings leave Live');
-- // 5. Effects need CHAT_AUTHORITY=chat.
-- process.env.CHAT_AUTHORITY = '';
-- assert.strictEqual((await call('POST', '/internal/chat-effects/user-color', { token: WRITE, body: { user_id: viewer, color: '#ff00ff' } })).status, 409);
-- process.env.CHAT_AUTHORITY = 'chat';
++ // 5. Effects: the live.chat_effects.write token is the gate (a read token got 403 above); there is
++ // no CHAT_AUTHORITY mode since C-01.
assert.strictEqual((await call('POST', '/internal/chat-effects/user-color', { token: WRITE, body: { user_id: viewer, color: '#ff00ff' } })).status, 200);
assert.strictEqual((await db.getUserById(viewer)).profile_color, '#ff00ff');
@@@ -660,9 +657,11 @@ const waitFor = async (pred, what, ms = 3000) => {
readState.down = false;
chatReads._reset();
-- // (5) The home-stats peek outside chat mode: a messages-only zero (Live keeps no
-- // chat_messages since the drop, so there is nothing local to count).
-- process.env.CHAT_AUTHORITY = '';
++ // (5) The home-stats peek when Live cannot ask Chat (as in development and tests):
++ // a messages-only zero (Live keeps no chat_messages since the drop, so there is nothing local to count).
++ const chatClient = require('../server/chat/chat-client');
++ const available = chatClient.available;
++ chatClient.available = () => false;
try {
chatReads._reset();
const peeked = chatReads.siteStatsPeek();
@@@ -671,7 +670,7 @@ const waitFor = async (pred, what, ms = 3000) => {
const full = await chatReads.siteStats();
assert.strictEqual(full.messages, 0, 'the full local read is zero too');
} finally {
-- process.env.CHAT_AUTHORITY = 'chat';
++ chatClient.available = available;
chatReads._reset();
}
ddiff --git a/test/chat-history-store.test.js b/test/chat-history-store.test.js
iindex 24b4b44a..7f236ec7 100644
---- a/test/chat-history-store.test.js
++++ b/test/chat-history-store.test.js
@@@ -14,7 +14,7 @@ const path = require('path');
const ROOT = path.join(__dirname, '..');
assert.ok(!fs.existsSync(path.join(ROOT, 'server', 'chat', 'history-store.js')), 'history-store.js is deleted — history is Chat\'s now');
--delete process.env.CHAT_AUTHORITY; // not chat mode: the seam answers its empty local answers
++// No Chat here (the preload leaves OV_CHAT_INTERNAL_URL empty): the seam answers its empty local answers.
const db = require('../server/db/database');
const reads = require('../server/chat/chat-reads');
ddiff --git a/test/chat-ingress.test.js b/test/chat-ingress.test.js
iindex 8a6fc1f6..29666512 100644
---- a/test/chat-ingress.test.js
++++ b/test/chat-ingress.test.js
@@@ -1,6 +1,6 @@
'use strict';
--// CHAT_AUTHORITY=chat (T3 J2): Live's chat producers call OpenVibe.Chat's typed service-token
++// T3 J2: Live's chat producers call OpenVibe.Chat's typed service-token
// ingress (/internal/chat/messages|events|moderation|invalidate, presence) through
// server/chat/chat-delivery.js — the only path; the old bridge (/internal/live/calls) and its outbox
// are gone. Each producer family reaches its endpoint with a bearer token and a stable idempotency
@@@ -23,7 +23,6 @@ process.env.OV_NETWORK_PUBLIC_KEY = path.join(tmp, 'network.pem');
process.env.OV_NETWORK_URL = 'https://openvibe.network';
process.env.OV_OAUTH_CLIENT_ID = 'live';
process.env.OV_OAUTH_CLIENT_SECRET = 'live-secret';
--process.env.CHAT_AUTHORITY = 'chat';
const quiet = console.log;
console.log = () => {};
console.warn = () => {};
@@@ -133,7 +132,7 @@ function assertSigned(c, what) {
const streamId = Number((await db.createStream({ user_id: streamer, channel_id: channel.id, title: 'Live now' })).lastInsertRowid);
const delivery = require('../server/chat/chat-delivery');
-- assert.strictEqual(delivery.ingress(), true, 'CHAT_AUTHORITY=chat is ingress, with no flag');
++ assert.ok(!('ingress' in delivery), 'Chat\'s ingress is the only path: no mode to ask about (C-01)');
await delivery.init();
delivery.client._setRetryMs([20, 20]);
@@@ -300,14 +299,15 @@ function assertSigned(c, what) {
const direct = calls.slice(n).map((c) => [c.family, (c.body.target && c.body.target.kind) || c.body.action || (c.body.user != null ? 'user' : null)]);
assert.deepStrictEqual(direct.sort(), [['events', 'stream'], ['events', 'user'], ['invalidate', 'user'], ['moderation', 'disconnect']].sort());
assert.ok(!JSON.stringify(calls.slice(n)).includes('never'), 'only the public account fields');
-- // CHAT_AUTHORITY unset under a RemoteChatServer (a test or a bad env edit): dropped, never bounced back.
++ // CHAT_AUTHORITY is ignored now (C-01): a leftover empty value still delivers to Chat.
process.env.CHAT_AUTHORITY = '';
-- assert.strictEqual(delivery.ingress(), false);
-- assert.strictEqual(await delivery.broadcastToStream(streamId, { type: 'system', message: 'x' }), undefined);
-- process.env.CHAT_AUTHORITY = 'chat';
++ const pushesBefore = calls.length;
++ await delivery.broadcastToStream(streamId, { type: 'system', message: 'still delivered' });
++ await waitFor(() => calls.length > pushesBefore, 'a push with CHAT_AUTHORITY unset');
++ delete process.env.CHAT_AUTHORITY;
delivery.close();
-- quiet('chat ingress (CHAT_AUTHORITY=chat): all checks passed');
++ quiet('chat ingress: all checks passed');
} catch (err) {
console.error(err);
exit = 1;
ddiff --git a/test/helpers/pg-preload.mjs b/test/helpers/pg-preload.mjs
iindex a2c46e45..bf91f64a 100644
---- a/test/helpers/pg-preload.mjs
++++ b/test/helpers/pg-preload.mjs
@@@ -8,6 +8,9 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
const require = createRequire(import.meta.url);
++// Tests never reach a real OpenVibe.Chat: with an empty address there is no Chat to ask (server/chat/chat-client.js),
++// as with CHAT_AUTHORITY unset before C-01. A test that exercises Chat points OV_CHAT_INTERNAL_URL at its own stub.
++process.env.OV_CHAT_INTERNAL_URL ??= '';
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
const MIGRATIONS = path.join(root, 'migrations');
const store = process.env.LIVE_TEST_STORE || 'pglite';
ddiff --git a/test/tips-delivery.test.js b/test/tips-delivery.test.js
iindex 10eae6b7..18abd0b9 100644
---- a/test/tips-delivery.test.js
++++ b/test/tips-delivery.test.js
@@@ -38,8 +38,7 @@ const token = (cap, aud = 'openvibe.live') => serviceAuth.signServiceToken({ iss
await d.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (501, 'network', '77', ?)").run(SUBJECT);
// Chat persists and shows the line (Live keeps no chat table): stub the ingress call and record
-- // what Live sends, and keep stubbing TTS synthesis (Chat speaks tts lines; the delivery seam is
-- // Live's only path).
++ // what Live sends. Chat speaks tts lines, so Live never synthesises (the stub proves it).
const delivery = require('../server/chat/chat-delivery');
const sent = [];
let nextId = 700;
@@@ -114,11 +113,16 @@ const token = (cap, aud = 'openvibe.live') => serviceAuth.signServiceToken({ iss
assert.strictEqual((await d.prepare("SELECT state FROM tips_deliveries WHERE idempotency_key = 'tint_1:chat_line'").get()).state, 'done');
server2.close();
-- // TTS on the (offline) channel room.
++ // TTS: a `tts` line in the channel, which Chat reads aloud under the same keys.
const t = await post({ ...job, effect: 'tts', tts: { text: 'read me', voice: 'gary' } }, { key: 'tint_1:tts' });
assert.strictEqual(t.status, 200);
-- assert.strictEqual(tts[0][2], 'read me');
-- assert.strictEqual(tts[0][6], 501);
++ const line = sent[sent.length - 1];
++ assert.strictEqual(line.message, 'read me');
++ assert.strictEqual(line.message_type, 'tts');
++ assert.strictEqual(line.channel_user_id, 501);
++ assert.deepStrictEqual(line.tts, { identity_key: 'tips:tint_1', key: 'tips-tint_1' });
++ assert.strictEqual(t.json.ref.chat_message_id, nextId - 1);
++ assert.deepStrictEqual(tts, [], 'Live never synthesises');
// A hidden amount (Tips privacy.hide_amount sends null) stays hidden: no "0 Vibes" anywhere.
const h = await post({ ...job, text: undefined, interaction: { ...job.interaction, id: 'tint_h', amount: null, message: 'hi' } }, { key: 'tint_h:chat_line' });