openvibers/openvibe.live Commits
Live: CHAT_AUTHORITY is retired; Chat runs chat with no switch (C-01) (#94)
Changes
@@@ -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@@@ -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 mountsnone 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`,@@@ -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 · /relay-user/:platform/:username</td><td>Opt</td><td>Anon / relay chatter meta (+ <code>/logs</code>, auth)</td></tr>@@@ -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 */ }};ddeleted file mode 100644@@@ -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 };@@@ -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, masync 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,@@@ -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,@@@ -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 -> Promiselet cacheMax = 2000; // test hook; the cap remember()/rememberFailure() evict down tolet 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. */@@@ -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;@@@ -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);@@@ -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;@@@ -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.@@@ -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'));@@@ -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 */ }}@@@ -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}@@@ -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@@@ -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; }@@@ -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];@@@ -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({ limif (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');@@@ -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');@@@ -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.tconst 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); });@@@ -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();}@@@ -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');@@@ -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;@@@ -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';@@@ -38,8 +38,7 @@ const token = (cap, aud = 'openvibe.live') => serviceAuth.signServiceToken({ issawait 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({ issassert.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' });
@@ -156,14 +156,12 @@ OV_COMMUNITY_URL=https://openvibe.community
@@ -156,14 +156,12 @@ OV_COMMUNITY_URL=https://openvibe.community
# and community.comment.moderate on openvibe.community)
# and community.comment.moderate on openvibe.community)
OV_COMMUNITY_INTERNAL_URL=http://127.0.0.1:4200
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
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
# 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
# (PUT /api/streams/channel/:username/bot) and adds bot_embed { enabled, robot_id, url } to the channel
@@ -11,9 +11,9 @@ The OpenVibe.Live chat system provides real-time messaging, moderation, and exte
@@ -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)
### 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
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
- 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.
none of them, and a `/ws/chat` upgrade that still lands on Live is destroyed.
@@ -43,8 +43,7 @@ path are deleted.
@@ -43,8 +43,7 @@ path are deleted.
- Chat reads Live data and asks for side effects on `/internal/chat-context/*` and
- 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
`/internal/chat-effects/*` (`server/chat/live-context-routes.js`), with Network service tokens
(`live.chat_context.read`, `live.chat_effects.write`,
(`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 —
- 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`),
`POST /internal/chat/stats` (`site`, `user`, `stream`, `channel-top` and `site-daily`),
`GET /internal/chat/messages`, `/timeline`, `/first-chat`, `/sounds`, `/sounds/by-command`,
`GET /internal/chat/messages`, `/timeline`, `/first-chat`, `/sounds`, `/sounds/by-command`,
@@ -1247,7 +1247,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
@@ -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>
<thead><tr><th>Method</th><th>Path</th><th>Auth</th><th>Notes</th></tr></thead>
<tbody>
<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>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/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/: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 · /relay-user/:platform/:username</td><td>Opt</td><td>Anon / relay chatter meta (+ <code>/logs</code>, auth)</td></tr>
<tr><td>GET</td><td>/api/chat/anon/:anonId · /relay-user/:platform/:username</td><td>Opt</td><td>Anon / relay chatter meta (+ <code>/logs</code>, auth)</td></tr>
@@ -26,7 +26,7 @@ const stats = { received: 0, revoked: 0, unchanged: 0, profiles: 0, ignored: 0,
@@ -26,7 +26,7 @@ const stats = { received: 0, revoked: 0, unchanged: 0, profiles: 0, ignored: 0,
const notifyChat = async (userId) => {
const notifyChat = async (userId) => {
try {
try {
const delivery = require('../chat/chat-delivery');
const delivery = require('../chat/chat-delivery');
- if (delivery.ingress()) await delivery.invalidate({ user: Number(userId) });
+ await delivery.invalidate({ user: Number(userId) });
} catch { /* non-critical */ }
} catch { /* non-critical */ }
};
};
deleted file mode 100644
deleted file mode 100644
@@ -1,27 +0,0 @@
@@ -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 };
@@ -35,7 +35,10 @@
@@ -35,7 +35,10 @@
*/
*/
const crypto = require('crypto');
const crypto = require('crypto');
const principal = require('../net/network-principal');
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 AUDIENCE = 'openvibe.chat';
const TIMEOUT_MS = 5000;
const TIMEOUT_MS = 5000;
@@ -49,6 +52,8 @@ let lastLog = null;
@@ -49,6 +52,8 @@ let lastLog = null;
let retryMs = RETRY_MS;
let retryMs = RETRY_MS;
function drill() { try { return require('../drill').enabled; } catch { return false; } }
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) {
function count(family, result) {
const f = stats.byFamily[family] || (stats.byFamily[family] = { sent: 0, refused: 0, failed: 0, dropped: 0 });
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
@@ -88,6 +93,7 @@ const sleep = (ms) => new Promise((resolve) => { const t = setTimeout(resolve, m
async function send(family, body, path = `/internal/chat/${family}`) {
async function send(family, body, path = `/internal/chat/${family}`) {
if (drill()) return null;
if (drill()) return null;
if (!principal.configured()) { count(family, 'dropped'); note(`${family}: not configured (OV_OAUTH_CLIENT_SECRET unset); dropped`); 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) };
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; }
if (waiting >= MAX_WAITING) { count(family, 'dropped'); note(`${family}: ${waiting} operations already waiting for Chat; dropped`); return null; }
waiting++;
waiting++;
@@ -120,7 +126,7 @@ async function send(family, body, path = `/internal/chat/${family}`) {
@@ -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. */
/** Chat's presence snapshot ({ total, streams, slow_mode, users, anons }), or null. */
async function presence() {
async function presence() {
- if (drill() || !principal.configured()) return null;
+ if (!available()) return null;
try {
try {
const { status, data } = await request('GET', '/internal/chat/presence');
const { status, data } = await request('GET', '/internal/chat/presence');
if (status === 200 && data) return data;
if (status === 200 && data) return data;
@@ -148,7 +154,7 @@ function qs(params) {
@@ -148,7 +154,7 @@ function qs(params) {
/** GET one Chat read; its JSON body or null. Ordinary failures are logged and answered null. */
/** GET one Chat read; its JSON body or null. Ordinary failures are logged and answered null. */
async function read(name, path) {
async function read(name, path) {
- if (drill() || !principal.configured()) return null;
+ if (!available()) return null;
try {
try {
const { status, data } = await request('GET', path);
const { status, data } = await request('GET', path);
if (status === 200 && data && data.ok !== false) return data;
if (status === 200 && data && data.ok !== false) return data;
@@ -159,7 +165,7 @@ async function read(name, path) {
@@ -159,7 +165,7 @@ async function read(name, path) {
/** POST /internal/chat/stats — message/chatter counts, or the top chatters of a room. */
/** POST /internal/chat/stats — message/chatter counts, or the top chatters of a room. */
async function readStats(body) {
async function readStats(body) {
- if (drill() || !principal.configured()) return null;
+ if (!available()) return null;
try {
try {
const { status, data } = await request('POST', '/internal/chat/stats', body || {});
const { status, data } = await request('POST', '/internal/chat/stats', body || {});
if (status === 200 && data && data.ok !== false) return data;
if (status === 200 && data && data.ok !== false) return data;
@@ -184,7 +190,7 @@ const readDmBlockState = async (params) => await read('dm-block-state', `/intern
@@ -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.
* falls back to Live's table rather than silencing every !sound.
*/
*/
async function readSoundByCommand(params) {
async function readSoundByCommand(params) {
- if (drill() || !principal.configured()) return null;
+ if (!available()) return null;
try {
try {
const { status, data } = await request('GET', `/internal/chat/sounds/by-command${qs(params)}`);
const { status, data } = await request('GET', `/internal/chat/sounds/by-command${qs(params)}`);
if (status === 200 && data && data.ok !== false && data.sound) return data;
if (status === 200 && data && data.ok !== false && data.sound) return data;
@@ -195,7 +201,7 @@ async function readSoundByCommand(params) {
@@ -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). */
/** Record where Live's asset sync uploaded a channel sound (idempotent; Chat applies the same write once). */
async function soundAsset(body) {
async function soundAsset(body) {
- if (drill() || !principal.configured()) return null;
+ if (!available()) return null;
try {
try {
// Chat's /sounds/asset takes exactly { id, media_url, media_asset_id } — no idempotency key.
// 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);
const { status, data } = await request('POST', '/internal/chat/sounds/asset', body);
@@ -206,6 +212,8 @@ async function soundAsset(body) {
@@ -206,6 +212,8 @@ async function soundAsset(body) {
}
}
module.exports = {
module.exports = {
+ CHAT_URL,
+ available,
AUDIENCE,
AUDIENCE,
key,
key,
stats,
stats,
@@ -2,20 +2,20 @@
@@ -2,20 +2,20 @@
/**
/**
* The one seam Live's chat producers call (T3 J2; Live's own ChatServer is deleted).
* 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.
* 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 }
* 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)
* logModeration(entry) a moderation-log row (Chat's moderation_actions shape)
* disconnect({ userId, ip, streamId })
* 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
* 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,
* The pushes Live's modules used to make on the chat server keep their names here — broadcastToStream,
@@ -31,11 +31,8 @@
@@ -31,11 +31,8 @@
* which send Chat a cache hint instead of waiting for its cache to expire.
* which send Chat a cache hint instead of waiting for its cache to expire.
*/
*/
const db = require('../db/database');
const db = require('../db/database');
-const chatAuthority = require('./chat-authority');
const client = require('./chat-client');
const client = require('./chat-client');
-function ingress() { return chatAuthority.isRemote(); }
-
function defined(o) {
function defined(o) {
const out = {};
const out = {};
for (const [k, v] of Object.entries(o || {})) if (v !== undefined) out[k] = v;
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') {
@@ -67,7 +64,6 @@ function dropped(name, why = 'has no Chat ingress target and was dropped') {
}
}
async function event(target, frame, { key } = {}) {
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 };
const t = { kind: target.kind };
if (target.id != null) t.id = Number(target.id);
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) });
return await client.event({ key: key ? client.key('events', key) : undefined, target: t, frame: defined(frame) });
@@ -88,15 +84,12 @@ async function logModeration(entry) {
@@ -88,15 +84,12 @@ async function logModeration(entry) {
}
}
async function disconnect({ userId, ip, streamId } = {}) {
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 });
return await moderate('disconnect', { user_id: userId || undefined, ip: ip || undefined, stream_id: streamId || undefined });
}
}
const disconnectUser = disconnect;
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) {
function after(value, fn) {
@@ -159,15 +152,16 @@ async function _pollPresence() {
@@ -159,15 +152,16 @@ async function _pollPresence() {
} catch { /* keep the last snapshot */ }
} 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() {
async function init() {
- if (!ingress()) return null;
+ if (!client.available()) return null;
if (!_dbObserved) { _dbObserved = true; _observeDb(); }
if (!_dbObserved) { _dbObserved = true; _observeDb(); }
if (_presenceTimer) return null;
if (_presenceTimer) return null;
_pollPresence().catch((e) => console.warn('[Chat] presence:', e.message));
_pollPresence().catch((e) => console.warn('[Chat] presence:', e.message));
_presenceTimer = setInterval(() => _pollPresence().catch((e) => console.warn('[Chat] presence:', e.message)), PRESENCE_MS);
_presenceTimer = setInterval(() => _pollPresence().catch((e) => console.warn('[Chat] presence:', e.message)), PRESENCE_MS);
if (_presenceTimer.unref) _presenceTimer.unref();
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;
return null;
}
}
@@ -308,8 +302,7 @@ async function resolveAnon(ip) {
@@ -308,8 +302,7 @@ async function resolveAnon(ip) {
return { anon_number: num, first_seen: await db.getAnonFirstSeen(key) };
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,
broadcastToStream, broadcastToChannelRoom, broadcastGlobal, broadcastAll, sendDm, sendUserUpdate,
forwardToGlobal, forwardToStreamerRooms, triggerChannelSound, synthesizeAndBroadcastTTS,
forwardToGlobal, forwardToStreamerRooms, triggerChannelSound, synthesizeAndBroadcastTTS,
init, close, slowModeByStream, getTotalConnections, getStreamViewerCount, getConnectedUserIp, findClientByAnonId,
init, close, slowModeByStream, getTotalConnections, getStreamViewerCount, getConnectedUserIp, findClientByAnonId,
@@ -6,11 +6,11 @@
@@ -6,11 +6,11 @@
* recaps, AI context, admin console, mod queues and the call-invite gate read them through Chat's
* 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
* 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
* (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
* 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
* (for a caller that cannot await) answers the last good value while that refresh runs, so a peek
@@ -26,7 +26,6 @@
@@ -26,7 +26,6 @@
* Callers that can await use the async function; the few that cannot (the AI persona prompt, the
* 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*().
* home-stats snapshot, the TTS engine) use the matching peek*().
*/
*/
-const { isRemote } = require('./chat-authority');
const client = require('./chat-client');
const client = require('./chat-client');
const CACHE_TTL_MS = 15_000; // how long an answer is used without asking Chat again
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
@@ -38,8 +37,7 @@ const inflight = new Map(); // key -> Promise
let cacheMax = 2000; // test hook; the cap remember()/rememberFailure() evict down to
let cacheMax = 2000; // test hook; the cap remember()/rememberFailure() evict down to
let lastLog = null;
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'); }
function local() { return require('../db/database'); }
/** Run a Live-table read for a synchronous caller, answering `dflt` if it throws. Still used for
/** 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. */
* the non-chat home metrics (users, follows, …), which Live owns. */
@@ -15,7 +15,7 @@
@@ -15,7 +15,7 @@
* outage costs the insight text and never a request. Off under LIVE_DRILL (a restore drill calls no
* outage costs the insight text and never a request. Off under LIVE_DRILL (a restore drill calls no
* other service).
* other service).
*/
*/
-const { CHAT_URL } = require('./chat-authority');
+const { CHAT_URL } = require('./chat-client');
const CACHE_TTL_MS = 60_000;
const CACHE_TTL_MS = 60_000;
const TIMEOUT_MS = 4000;
const TIMEOUT_MS = 4000;
@@ -22,7 +22,7 @@
@@ -22,7 +22,7 @@
* GET /anon/:num, /anon-first-seen?ip → { first_seen }
* GET /anon/:num, /anon-first-seen?ip → { first_seen }
* GET /tts-audio/:file → a clip from the arena voice cache
* 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,
* 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,
* 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
* media-queue, hardware, paste, translate, notify/dm, notify/dm-read, notify/call-invite, asset-sync
@@ -37,7 +37,6 @@ const db = require('../db/database');
@@ -37,7 +37,6 @@ const db = require('../db/database');
const config = require('../config');
const config = require('../config');
const permissions = require('../auth/permissions');
const permissions = require('../auth/permissions');
const { guard } = require('../net/service-guard');
const { guard } = require('../net/service-guard');
-const { isRemote } = require('./chat-authority');
const contextRouter = express.Router();
const contextRouter = express.Router();
const effectsRouter = express.Router();
const effectsRouter = express.Router();
@@ -252,13 +251,9 @@ contextRouter.get('/tts-audio/:file', (req, res) => {
@@ -252,13 +251,9 @@ contextRouter.get('/tts-audio/:file', (req, res) => {
// ── Effects ────────────────────────────────────────────────────
// ── 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'));
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 chatDelivery = () => require('./chat-delivery');
const actorOf = async (id) => (id ? await db.getUserById(id) : null);
const actorOf = async (id) => (id ? await db.getUserById(id) : null);
@@ -20,7 +20,7 @@
@@ -20,7 +20,7 @@
* Off under LIVE_DRILL (a restore drill calls no other service) and without OV_OAUTH_CLIENT_SECRET.
* Off under LIVE_DRILL (a restore drill calls no other service) and without OV_OAUTH_CLIENT_SECRET.
*/
*/
const { createServiceTokenClient } = require('openvibe-sdk/auth');
const { createServiceTokenClient } = require('openvibe-sdk/auth');
-const { CHAT_URL } = require('./chat-authority');
+const { CHAT_URL } = require('./chat-client');
const AUDIENCE = 'openvibe.chat';
const AUDIENCE = 'openvibe.chat';
const CACHE_TTL_MS = 30_000;
const CACHE_TTL_MS = 30_000;
@@ -1442,7 +1442,7 @@ async function homeSeriesLocal(metric, days = 30) {
@@ -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
* `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);
* stale-while-revalidate peek that falls back to Live's own tables when Chat is unreachable);
* every other metric is Live's own.
* every other metric is Live's own.
@@ -740,7 +740,7 @@ app.use('/api/streams', streamRoutes);
@@ -740,7 +740,7 @@ app.use('/api/streams', streamRoutes);
const liveEvents = require('./streaming/live-events');
const liveEvents = require('./streaming/live-events');
app.get('/api/live-events', (req, res) => liveEvents.subscribe(req, res));
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
// 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/funds', monetizationRoutes);
app.use('/api/coins', coinsRoutes);
app.use('/api/coins', coinsRoutes);
app.use('/api/payments', require('./monetization/payments-routes'));
app.use('/api/payments', require('./monetization/payments-routes'));
@@ -12,11 +12,11 @@ const { bindSlot, BindError } = require('../openre/bind');
@@ -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
// 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).
// 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) {
async function notifyChat(userId) {
try {
try {
const delivery = require('../chat/chat-delivery');
const delivery = require('../chat/chat-delivery');
- if (delivery.ingress()) await delivery.invalidate({ user: Number(userId) });
+ await delivery.invalidate({ user: Number(userId) });
} catch { /* non-critical */ }
} catch { /* non-critical */ }
}
}
@@ -899,11 +899,7 @@ class MediaQueue {
@@ -899,11 +899,7 @@ class MediaQueue {
try {
try {
const delivery = require('../chat/chat-delivery');
const delivery = require('../chat/chat-delivery');
// Chat's ingress fans a media frame out to the owner's live streams itself.
// 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 {
} catch {
// optional
// optional
}
}
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
* alerts.js — streamer alert sounds for donations / donation-goal-reached events.
* 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
* 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),
* 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
* 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
* `soundboard-audio` message to the channel room. A goal-reached event is `kind: 'goal'` (Chat falls back
@@ -179,8 +179,7 @@ async function announce(recap) {
@@ -179,8 +179,7 @@ async function announce(recap) {
const delivery = require('../chat/chat-delivery');
const delivery = require('../chat/chat-delivery');
const g = recap.write.grade;
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}` };
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]);
await db.run('UPDATE stream_recaps SET announced_at = ov_now() WHERE stream_id = ?', [recap.stream.id]);
return true;
return true;
} catch (e) { console.warn('[Recap] announce:', e.message); return false; }
} catch (e) { console.warn('[Recap] announce:', e.message); return false; }
@@ -11,7 +11,7 @@
@@ -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
* 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.
* is logged and dropped.
*/
*/
-const { CHAT_URL } = require('../chat/chat-authority');
+const { CHAT_URL } = require('../chat/chat-client');
const AUDIENCE = 'openvibe.chat';
const AUDIENCE = 'openvibe.chat';
const RETRY_MS = [1000, 5000, 15000];
const RETRY_MS = [1000, 5000, 15000];
@@ -17,7 +17,7 @@
@@ -17,7 +17,7 @@
*
*
* chat_line | paid_message donation event to the channel room + global, saved as a 'donation'
* chat_line | paid_message donation event to the channel room + global, saved as a 'donation'
* chat message (metadata.source 'tips'), the donation alert sound
* 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
* media_request into the media queue at cost 0 — Billing charged it, Tips holds the record
*/
*/
const express = require('express');
const express = require('express');
@@ -122,16 +122,12 @@ router.post('/deliveries', guard('live.tips_delivery.write'), express.json({ lim
@@ -122,16 +122,12 @@ router.post('/deliveries', guard('live.tips_delivery.write'), express.json({ lim
if (b.effect === 'tts') {
if (b.effect === 'tts') {
const text = String((b.tts && b.tts.text) || '').slice(0, 1200);
const text = String((b.tts && b.tts.text) || '').slice(0, 1200);
if (!text) return res.status(422).json({ error: 'no text' });
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') {
if (b.effect === 'media_request') {
const mq = require('../media/media-queue');
const mq = require('../media/media-queue');
@@ -31,8 +31,8 @@ assert.deepStrictEqual(calls.map(({ target, frame }) => [target.kind, target.id,
@@ -31,8 +31,8 @@ assert.deepStrictEqual(calls.map(({ target, frame }) => [target.kind, target.id,
['channel', 7, 7, null, 'goal'],
['channel', 7, 7, null, 'goal'],
], 'unknown kinds fall back to the donation sound, and the alert targets the channel owner');
], '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'));
assert.doesNotThrow(() => alerts.playAlertSound(7, 3, 'donation'));
console.log('alerts event: all checks passed');
console.log('alerts event: all checks passed');
@@ -23,7 +23,8 @@ const offenders = [];
@@ -23,7 +23,8 @@ const offenders = [];
if (/chat_bridge_outbox/i.test(src)) offenders.push(`${rel}: chat_bridge_outbox`);
if (/chat_bridge_outbox/i.test(src)) offenders.push(`${rel}: chat_bridge_outbox`);
if (/chat-remote/.test(src)) offenders.push(`${rel}: chat-remote`);
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 (/\/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'));
})(path.join(ROOT, 'server'));
assert.deepStrictEqual(offenders, [], `server/ still uses the old chat bridge:\n${offenders.join('\n')}`);
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
@@ -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');
const db = require('../server/db/database');
await db.initDb();
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');
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);
process.exit(0);
})().catch((e) => { console.error(e); process.exit(1); });
})().catch((e) => { console.error(e); process.exit(1); });
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
// Chat moved to OpenVibe.Chat (roadmap Wave 6). What Live answers it on /internal/chat-context/*
// 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
// 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.
// over Chat's typed ingress; no bridge, no outbox). Against stub Network and Chat servers.
const assert = require('assert');
const assert = require('assert');
@@ -23,7 +23,6 @@ process.env.OV_NETWORK_URL = 'https://openvibe.network';
@@ -23,7 +23,6 @@ process.env.OV_NETWORK_URL = 'https://openvibe.network';
process.env.OV_OAUTH_CLIENT_ID = 'live';
process.env.OV_OAUTH_CLIENT_ID = 'live';
process.env.OV_OAUTH_CLIENT_SECRET = 'live-secret';
process.env.OV_OAUTH_CLIENT_SECRET = 'live-secret';
process.env.SOUNDS_PATH = path.join(tmp, 'sounds');
process.env.SOUNDS_PATH = path.join(tmp, 'sounds');
-process.env.CHAT_AUTHORITY = 'chat';
const quiet = console.log;
const quiet = console.log;
console.log = () => {};
console.log = () => {};
console.warn = () => {};
console.warn = () => {};
@@ -369,10 +368,8 @@ const waitFor = async (pred, what, ms = 3000) => {
@@ -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.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');
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 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');
assert.strictEqual((await db.getUserById(viewer)).profile_color, '#ff00ff');
@@ -660,9 +657,11 @@ const waitFor = async (pred, what, ms = 3000) => {
@@ -660,9 +657,11 @@ const waitFor = async (pred, what, ms = 3000) => {
readState.down = false;
readState.down = false;
chatReads._reset();
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 {
try {
chatReads._reset();
chatReads._reset();
const peeked = chatReads.siteStatsPeek();
const peeked = chatReads.siteStatsPeek();
@@ -671,7 +670,7 @@ const waitFor = async (pred, what, ms = 3000) => {
@@ -671,7 +670,7 @@ const waitFor = async (pred, what, ms = 3000) => {
const full = await chatReads.siteStats();
const full = await chatReads.siteStats();
assert.strictEqual(full.messages, 0, 'the full local read is zero too');
assert.strictEqual(full.messages, 0, 'the full local read is zero too');
} finally {
} finally {
- process.env.CHAT_AUTHORITY = 'chat';
+ chatClient.available = available;
chatReads._reset();
chatReads._reset();
}
}
@@ -14,7 +14,7 @@ const path = require('path');
@@ -14,7 +14,7 @@ const path = require('path');
const ROOT = path.join(__dirname, '..');
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');
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 db = require('../server/db/database');
const reads = require('../server/chat/chat-reads');
const reads = require('../server/chat/chat-reads');
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
'use strict';
'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
// 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
// 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
// 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');
@@ -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_NETWORK_URL = 'https://openvibe.network';
process.env.OV_OAUTH_CLIENT_ID = 'live';
process.env.OV_OAUTH_CLIENT_ID = 'live';
process.env.OV_OAUTH_CLIENT_SECRET = 'live-secret';
process.env.OV_OAUTH_CLIENT_SECRET = 'live-secret';
-process.env.CHAT_AUTHORITY = 'chat';
const quiet = console.log;
const quiet = console.log;
console.log = () => {};
console.log = () => {};
console.warn = () => {};
console.warn = () => {};
@@ -133,7 +132,7 @@ function assertSigned(c, what) {
@@ -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 streamId = Number((await db.createStream({ user_id: streamer, channel_id: channel.id, title: 'Live now' })).lastInsertRowid);
const delivery = require('../server/chat/chat-delivery');
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();
await delivery.init();
delivery.client._setRetryMs([20, 20]);
delivery.client._setRetryMs([20, 20]);
@@ -300,14 +299,15 @@ function assertSigned(c, what) {
@@ -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)]);
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.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');
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 = '';
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();
delivery.close();
- quiet('chat ingress (CHAT_AUTHORITY=chat): all checks passed');
+ quiet('chat ingress: all checks passed');
} catch (err) {
} catch (err) {
console.error(err);
console.error(err);
exit = 1;
exit = 1;
@@ -8,6 +8,9 @@ import path from 'node:path';
@@ -8,6 +8,9 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { fileURLToPath } from 'node:url';
const require = createRequire(import.meta.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 root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
const MIGRATIONS = path.join(root, 'migrations');
const MIGRATIONS = path.join(root, 'migrations');
const store = process.env.LIVE_TEST_STORE || 'pglite';
const store = process.env.LIVE_TEST_STORE || 'pglite';
@@ -38,8 +38,7 @@ const token = (cap, aud = 'openvibe.live') => serviceAuth.signServiceToken({ iss
@@ -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);
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
// 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 delivery = require('../server/chat/chat-delivery');
const sent = [];
const sent = [];
let nextId = 700;
let nextId = 700;
@@ -114,11 +113,16 @@ const token = (cap, aud = 'openvibe.live') => serviceAuth.signServiceToken({ iss
@@ -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');
assert.strictEqual((await d.prepare("SELECT state FROM tips_deliveries WHERE idempotency_key = 'tint_1:chat_line'").get()).state, 'done');
server2.close();
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' });
const t = await post({ ...job, effect: 'tts', tts: { text: 'read me', voice: 'gary' } }, { key: 'tint_1:tts' });
assert.strictEqual(t.status, 200);
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.
// 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' });
const h = await post({ ...job, text: undefined, interaction: { ...job.interaction, id: 'tint_h', amount: null, message: 'hi' } }, { key: 'tint_h:chat_line' });