openvibers/openvibe.live Commits

Delete the chat read mirror's receiver: POST /internal/chat-effects/mirror, delivery.mirror() and every call site; Chat retired the sender in #25 (#32)

OP OpenVibers committed 5 Oct 2026 · d237931747 · 16 files, +388 −251

* Delete the chat read mirror's receiver: POST /internal/chat-effects/mirror, delivery.mirror() and every call site; Chat retired the sender in #25

**Deletions**
- `server/chat/live-context-routes.js:260` — removed `POST /internal/chat-effects/mirror` + its `live.chat_mirror.write` special-case guard; the effects guard is now just `effectsRouter.use(guard('live.chat_effects.write'))`.
- `server/chat/live-context-routes.js:29,638-707` — removed the header `mirror` line, the read-mirror section (`MIRROR_TABLES`, `liveColumns`, `applyMirror`, the `/mirror` route) and their exports (`module.exports` is now `{ contextRouter, effectsRouter, userProjection }`).
- `server/chat/chat-delivery.js:19-20,103-116,311` — removed the `mirror()` doc entry, `MIRROR_WRITES` + `mirror()` and their exports.
- `server/admin/mod-routes.js:408,462,534,585,671,726,761,966,984` — removed every `delivery.mirror(...)` call; the `moderate(...)` branch and the non-chat `else db.*` branches are unchanged (`const opts` dropped with the bulk-delete mirrors).
- `server/integrations/chat-relay-service.js:506` — removed `delivery.mirror('recordRelayUser', …)` (the `relay-record` moderate stays).
- `server/integrations/chat-relay-service.js:499,516` — removed the `recordFirstChat` mirror call; the relay welcome still reads Live's own first-chat table (`:508`), so Live keeps its own record with a direct `db.recordFirstChat(...)` (the tables are kept until the later drop step, and `test/chat-ingress.test.js:154` asserts it).
- `server/integrations/robotstreamer-service.js:670` — removed `delivery.mirror('recordRelayUser', 'rs', …)` (moderate stays).
- `README.md:93`, `STATUS.json:12` — dropped `live.chat_mirror.write` from Live's answered capabilities.
- `docs/chat-system.md:49-63` — first bullet reworded; "What reads the mirror" replaced with "The read mirror is retired (2026-10-05)": Chat #25 removed the sender, this change the receiver.
- `server/chat/chat-authority.js:13` — retitled the stale "still keeps its chat tables as a read mirror Chat writes" comment.

**Tests**
- `test/chat-context.test.js` — deleted the `MIRROR` service-token constant and **section 9 ("The read mirror: same ids, Chat-only columns ignored, Live-only columns kept")**, the only section exercising the mirror receiver; sections 1-8, 8a/8b, 10-12 kept.
- `test/chat-ingress.test.js:197` — the delete-message check no longer expects Live's own copy to be mirrored-deleted (`is_deleted` now asserted `0`, "Live's own copy is untouched: Chat owns the delete").
- `test/chat-mirror-removed.test.js` — **new** grep-style guard (like `chat-bridge-removed.test.js`) that walks `server/` and fails on `chat-effects/mirror`, `live.chat_mirror.write`, `MIRROR_TABLES`, `applyMirror`, `liveColumns`, `MIRROR_WRITES` or `delivery.mirror`.
- `chat-reads.js` and `server/chat/` Live tables untouched; `frozen-tables`, `chat-staged-tables`, `drill-mode`, `chat-bridge-removed` left green.

**Tests run** (`nice -n 15 ov test --no-cache`, targeted only): 19 passed / 0 failed — `chat-context`, `chat-mirror-removed`, `chat-ingress`, `rs-chat-bridge-single-flight`, `rs-slot-only`, `floating-promise-fixes`, `authorization`, `ban-cascade`, `staff-capabilities`, `frozen-tables`, `chat-staged-tables`, `drill-mode`, `chat-bridge-removed`, plus `security-ssrf`, `security-redaction`, `dm-delivery`, `service-kit`, `tips-delivery`, `alerts-bridge`. No commit or push made.

* Chat-mode reads never fall back to Live's frozen tables: relay hides, TTS overrides, first chats and sounds from Chat; call invites check Chat's DM blocks and fail closed; asset-sync deletes the asset id it is given (review)

All findings fixed, tests green, working tree left uncommitted.

**Changes**

- `server/chat/chat-reads.js:7` — header: mirror retired; no remote read falls back to Live's frozen tables; mode note corrected (unsetting CHAT_AUTHORITY isn't a rollback).
- `server/chat/chat-reads.js:568-590` — `firstChat`/`firstChatPeek`: remote mode answers Chat only (last-good cached value, else `false`), never `firstChatLocal`.
- `server/chat/chat-reads.js:643-661` — `isRelayUserHidden`: remote = Chat's list only — fresh matches, else last good list + background refresh, never-answered fails open; `localHidden()` only when `!remote()`.
- `server/chat/chat-reads.js:679-686` — `ttsOverridePeek`: no local fallback in chat mode (null on cold/outage).
- `server/chat/chat-reads.js:711-723` — `soundByCommand`/`soundByCommandPeek`: no `channel_sounds` fallback; Chat's definitive none or unreachable → null.
- `server/chat/chat-reads.js:730-757` — new `dmBlocked(a,b)`: Chat block-state read, cached 30 s false / 5 s true, `strict` so an unverifiable answer is null (no last-good).
- `server/chat/chat-reads.js:811` — exports `dmBlocked`.
- `server/chat/chat-client.js:24,179,218` — `readDmBlockState` → `GET /internal/chat/dm/block-state` (capability `chat.messages.read`), documented and exported.
- `server/integrations/chat-relay-service.js:29` — bounded in-process `welcomedRelayUsers` Set.
- `server/integrations/chat-relay-service.js:516-545` — `_deliverToChat` is async: `await chatReads.firstChat(...)` before `delivery.message`; in-process dedupe + `chatReads.invalidate('fc:<chan>:<key>')` after deciding; local `isFirstChatInChannel`/`recordFirstChat` dropped from the ingress path.
- `server/integrations/chat-relay-service.js:406-410` — fire-and-forget call site with a catch.
- `server/integrations/chat-relay-service.js:394-396`, `server/integrations/robotstreamer-service.js:657-659,681-682` — comments now describe Chat-list/last-good/fail-open and no local sound fallback.
- `server/streaming/routes.js:1370,1386-1393` — call-user handler async; `chatReads.dmBlocked` in both modes; 403 when Chat says blocked, 503 "try again shortly" when unverifiable (fail closed).
- `server/chat/live-context-routes.js:625-637` — `/asset-sync` remove-sound deletes the asset id from the request; no read of Live's frozen `channel_sounds`.
- `server/ai/context/index.js:207-209` — stale "falls back to Live's mirror-kept table" comment updated.
- `test/chat-context.test.js:66,104-106,280` — stub Chat answers block-state (with `dmBlocked`/`down` controls); streaming routes mounted; `waitFor` helper.
- `test/chat-context.test.js:744-758` — remote `isRelayUserHidden` ignores a stale local hide (cold fail-open + warmed Chat list).
- `test/chat-context.test.js:927-941` — remote `ttsOverridePeek` ignores a stale local override.
- `test/chat-context.test.js:943-976` — relay welcome uses Chat's first-chat answer (both directions) and welcomes a second quick line only once.
- `test/chat-context.test.js:980-997` — asset-sync deletes exactly the asset id it was given.
- `test/chat-context.test.js:999-1025` — call invite: 403 when blocked, 200 when not, 503 when Chat is down.
- `test/chat-ingress.test.js:48-77,160-174,176-186` — stub answers first-chat/relay-users; welcome asserts Chat was asked, Live writes no local copy, second line deduped; hidden-user test warms Chat's list and invalidates on unhide.

**Tests** (`nice -n 15 ov test --no-cache`)
- `test/chat-context.test.js test/chat-ingress.test

Changes

ddiff --git a/README.md b/README.md
iindex c281e685..95409d03 100644
---- a/README.md
++++ b/README.md
@@@ -90,7 +90,7 @@ The media subsystem lives in **OpenVibe.Media**:
## Capabilities
--Live implements, for other services' tokens (manifest `manifests/services/live.json` in OpenVibe.Contracts): `live.chat_context.read`, `live.chat_effects.write`, `live.chat_mirror.write`, `live.lineage.resolve`, `live.tips_delivery.write`, `live.channel.read`, `live.stream.read`, `live.discovery.read`, `live.owner.resolve`.
++Live implements, for other services' tokens (manifest `manifests/services/live.json` in OpenVibe.Contracts): `live.chat_context.read`, `live.chat_effects.write`, `live.lineage.resolve`, `live.tips_delivery.write`, `live.channel.read`, `live.stream.read`, `live.discovery.read`, `live.owner.resolve`.
Live's own principal (`live`) holds grants to call:
ddiff --git a/STATUS.json b/STATUS.json
iindex 29016f38..69821b27 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -10,7 +10,6 @@
"capabilities": [
"live.chat_context.read",
"live.chat_effects.write",
-- "live.chat_mirror.write",
"live.lineage.resolve",
"live.tips_delivery.write",
"live.channel.read",
ddiff --git a/docs/chat-system.md b/docs/chat-system.md
iindex 0690f070..5eaeec29 100644
---- a/docs/chat-system.md
++++ b/docs/chat-system.md
@@@ -49,20 +49,20 @@ path are deleted.
`POST /internal/chat/stats` (`site`, `user`, `stream`, `channel-top` and `site-daily`),
`GET /internal/chat/messages`, `/timeline`, `/first-chat`, `/sounds`, `/sounds/by-command`,
`/moderation/pending-ip`, `/moderation/relay-users`, `/moderation/tts-override` — through
-- [server/chat/chat-reads.js](../server/chat/chat-reads.js), in place of the read mirror
-- (`POST /internal/chat-effects/mirror`) home stats, recaps, AI context, VOD chat replay and the
-- `/api/mod` queues used to read. A Chat outage answers a cached value or Live's own table, never a 500.
--- **What reads the mirror.** Nothing in chat mode any more: the two HOME_SERIES charts `messages`
-- and `active` read Chat's site-daily series, the AI context's "first time chatting here" flag reads
-- Chat's first-chat read, and the RobotStreamer `!sound` lookup reads Chat's sounds-by-command read —
-- each through [server/chat/chat-reads.js](../server/chat/chat-reads.js), falling back to Live's own
-- table only while Chat is unreachable. The mirror (`POST /internal/chat-effects/mirror`) therefore
-- has no chat-mode reader left and is removed in the remaining step: Chat stops its `LIVE_MIRROR`
-- writer, then Live deletes the receiver (`live-context-routes.js` `effectsRouter`'s mirror route and
-- its `live.chat_mirror.write` capability) and the mirror-kept chat tables. The readers that still
-- answer from Live's tables are on the Live-runs-chat path `CHAT_AUTHORITY=chat` leaves unmounted —
-- `dm_*` (no `/api/dm`), Live's chat server, the deploy notice, the admin chat log and the leftover
-- dev/rollback fallbacks.
++ [server/chat/chat-reads.js](../server/chat/chat-reads.js), replacing the read mirror for home stats,
++ recaps, AI context, VOD chat replay and the `/api/mod` queues. A Chat outage answers a cached value
++ or Live's own table, never a 500.
++- **The read mirror is retired** (2026-10-05): OpenVibe.Chat #25 removed the sender (its `LIVE_MIRROR`
++ writer and the capture triggers), and this change removed Live's receiver — the
++ `POST /internal/chat-effects/mirror` route in `live-context-routes.js`, its
++ `live.chat_mirror.write` capability and the `chat-delivery.js` mirror helper. Nothing in chat mode
++ read it any more: the two HOME_SERIES charts `messages` and `active` read Chat's site-daily series,
++ the AI context's "first time chatting here" flag reads Chat's first-chat read, and the
++ RobotStreamer `!sound` lookup reads Chat's sounds-by-command read — each through
++ [server/chat/chat-reads.js](../server/chat/chat-reads.js), falling back to Live's own table only
++ while Chat is unreachable. Live's own chat tables stay until a later change drops them; the readers
++ that still answer from them are on the Live-runs-chat path `CHAT_AUTHORITY=chat` leaves unmounted —
++ `dm_*` (no `/api/dm`), the deploy notice, the admin chat log and the leftover dev/rollback fallbacks.
- **The six chat tables are Chat's** (roadmap T3): `channel_moderators`, `channel_moderation_settings`,
`emotes`, `user_tags`, `chat_ai_summaries` and `chat_timeline_events`. Live keeps no copy and reads them
only through [server/chat/moderation-client.js](../server/chat/moderation-client.js) — Chat's internal
ddiff --git a/server/admin/mod-routes.js b/server/admin/mod-routes.js
iindex 88d51a36..ae69d8aa 100644
---- a/server/admin/mod-routes.js
++++ b/server/admin/mod-routes.js
@@@ -404,10 +404,8 @@ router.post('/delete-message', async (req, res) => {
if (!allowed) return res.status(403).json({ error: 'You cannot moderate this stream' });
}
-- // Chat ingress: Chat deletes the row and broadcasts the delete to every surface it reached;
-- // the mirror keeps Live's own copy in step for its remaining readers.
++ // Chat ingress: Chat deletes the row and broadcasts the delete to every surface it reached.
if (delivery.ingress()) {
-- delivery.mirror('deleteChatMessage', parseInt(message_id), req.user.id);
await delivery.moderate('delete-message', { id: parseInt(message_id), deleted_by: req.user.id }, { key: `delete:${message_id}` });
}
else db.deleteChatMessage(parseInt(message_id), req.user.id);
@@@ -465,10 +463,6 @@ router.post('/delete-user-messages', async (req, res) => {
// Chat deletes the rows and broadcasts the deletes (also the site-wide one) itself.
const [action, subject] = user_id ? ['delete-user-messages', { user_id: parseInt(user_id) }]
: anon_id ? ['delete-anon-messages', { anon_id: String(anon_id) }] : ['delete-relay-messages', { username: String(relay_username) }];
-- const opts = { streamId: scopedStreamId, deletedBy: req.user.id };
-- if (user_id) delivery.mirror('deleteUserChatMessages', parseInt(user_id), opts);
-- else if (anon_id) delivery.mirror('deleteAnonChatMessages', anon_id, opts);
-- else delivery.mirror('deleteRelayUserMessages', relay_username, opts);
const scoped = { stream_id: scopedStreamId ? parseInt(scopedStreamId) : undefined, deleted_by: req.user.id };
const r = await delivery.moderate(action, { ...subject, ...scoped });
ids = (r && Array.isArray(r.ids)) ? r.ids : [];
@@@ -538,7 +532,6 @@ router.post('/relay-user/hide', async (req, res) => {
// Unban / unhide — remove the ban for this exact relay identity only.
if (action === 'unban' || action === 'unhide') {
if (delivery.ingress()) {
-- delivery.mirror('unhideRelayUserByIdentity', channel_id || null, platform, external_username);
await delivery.moderate('relay-unhide', { channel_id: channel_id || undefined, platform, external_username });
}
else db.unhideRelayUserByIdentity(channel_id || null, platform, external_username);
@@@ -589,7 +582,7 @@ router.delete('/relay-user/:id', async (req, res) => {
const allowed = permissions.isGlobalModOrAbove(req.user)
|| (row.channel_id != null && await permissions.canModerateChannel(req.user, row.channel_id));
if (!allowed) return res.status(403).json({ error: 'You cannot moderate this channel' });
-- if (delivery.ingress()) { delivery.mirror('unhideRelayUser', row.id); await delivery.moderate('relay-unhide', { id: row.id }); }
++ if (delivery.ingress()) { await delivery.moderate('relay-unhide', { id: row.id }); }
else db.unhideRelayUser(row.id);
invalidateRelayHidden(row.channel_id);
@@@ -676,7 +669,6 @@ router.post('/ip-approval/:channelId/approve', async (req, res) => {
// Chat ingress: Chat approves its held rows; re-showing them live is Chat's (it holds them).
if (delivery.ingress()) {
-- delivery.mirror('approveAllFromIp', channelId, ip, req.user.id);
await delivery.moderate('approve-ip-messages', { channel_id: parseInt(channelId), ip, reviewed_by: req.user.id });
}
else db.approveAllFromIp(channelId, ip, req.user.id);
@@@ -732,7 +724,6 @@ router.post('/ip-approval/:channelId/deny', async (req, res) => {
}
if (delivery.ingress()) {
-- delivery.mirror('denyAllFromIp', channelId, ip, req.user.id);
await delivery.moderate('deny-ip-messages', { channel_id: parseInt(channelId), ip, reviewed_by: req.user.id });
}
else db.denyAllFromIp(channelId, ip, req.user.id);
@@@ -768,7 +759,6 @@ router.post('/ip-approval/:channelId/review', async (req, res) => {
}
if (delivery.ingress()) {
-- delivery.mirror('reviewPendingIpMessage', parseInt(message_id), { status, reviewedBy: req.user.id, channelId });
await delivery.moderate('review-pending-ip', { id: parseInt(message_id), status, reviewed_by: req.user.id, channel_id: parseInt(channelId) });
} else db.reviewPendingIpMessage(parseInt(message_id), {
status,
@@@ -973,7 +963,7 @@ router.put('/tts-voice/:kind/:id', permissions.requireGlobalMod, (req, res) => {
try {
const identityKey = _ttsIdentityKey(req.params.kind, req.params.id);
if (req.body?.reset) {
-- if (delivery.ingress()) { delivery.mirror('deleteTtsVoiceOverride', identityKey); delivery.moderate('tts-voice-override', { identity_key: identityKey }); }
++ if (delivery.ingress()) { delivery.moderate('tts-voice-override', { identity_key: identityKey }); }
else db.deleteTtsVoiceOverride(identityKey);
return res.json({ isOverride: false, params: ttsEngine.autoUserVoiceParams(identityKey) });
}
@@@ -991,7 +981,7 @@ router.put('/tts-voice/:kind/:id', permissions.requireGlobalMod, (req, res) => {
params = { voice: req.body?.voice, pitch: req.body?.pitch, speed: req.body?.speed, gap: req.body?.gap };
}
const clamped = ttsEngine.clampVoiceParams(params);
-- if (delivery.ingress()) { delivery.mirror('setTtsVoiceOverride', identityKey, clamped, req.user.id); delivery.moderate('tts-voice-override', { identity_key: identityKey, params: clamped, set_by: req.user.id }); }
++ if (delivery.ingress()) { delivery.moderate('tts-voice-override', { identity_key: identityKey, params: clamped, set_by: req.user.id }); }
else db.setTtsVoiceOverride(identityKey, clamped, req.user.id);
res.json({ isOverride: true, params: clamped });
} catch (err) {
ddiff --git a/server/ai/context/index.js b/server/ai/context/index.js
iindex 3df4b95d..c5f08412 100644
---- a/server/ai/context/index.js
++++ b/server/ai/context/index.js
@@@ -205,7 +205,8 @@ function personBlock(line, channelUserId, settings) {
}
// stream_first_chats is Chat's; a registered identity is `user:<user_id>` (never the
// username), a relay one `ext:<prefixed username>`. The welcome flag reads Chat's first-chat
-- // read through a sync peek that falls back to Live's own mirror-kept table while Chat is down.
++ // read through a sync peek; a cold cache or a Chat outage answers "not first" and never
++ // Live's own frozen copy.
const identity = line.userId ? `user:${line.userId}` : (line.anonId ? `anon:${line.anonId}` : `ext:${line.username}`);
if (line.userId !== channelUserId && settings.greet_first_timers && chatReads.firstChatPeek(channelUserId, identity)) flags.push('first time chatting here');
} catch { /* */ }
ddiff --git a/server/chat/chat-authority.js b/server/chat/chat-authority.js
iindex abc5878e..634ee3d5 100644
---- a/server/chat/chat-authority.js
++++ b/server/chat/chat-authority.js
@@@ -10,9 +10,8 @@
* 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), and still keeps its chat tables as a read
-- * mirror Chat writes (POST /internal/chat-effects/mirror) so the readers that have not moved
-- * yet keep working and a rollback loses nothing;
++ * (server/chat/chat-reads.js → chat-client.js); the read mirror Chat used to write back was
++ * retired on 2026-10-05 and Live keeps its chat tables only until a later change drops them;
* - 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.
ddiff --git a/server/chat/chat-client.js b/server/chat/chat-client.js
iindex 3b180e70..eada2e73 100644
---- a/server/chat/chat-client.js
++++ b/server/chat/chat-client.js
@@@ -21,6 +21,7 @@
* readTtsOverride(q) GET /internal/chat/moderation/tts-override chat.moderation.queue.read a voice override
* readSounds(q) GET /internal/chat/sounds chat.sounds.read a channel's sound count, or its pending assets
* readSoundByCommand(q) GET /internal/chat/sounds/by-command chat.sounds.read the approved sound a !command plays
++ * readDmBlockState(q) GET /internal/chat/dm/block-state chat.messages.read has either user blocked the other (either direction)
*
* Auth: Live's Network service principal (client_credentials, audience openvibe.chat, server/net/network-principal.js).
* Every POST carries one idempotency `key` made when the operation is created and reused on every retry, so Chat
@@@ -175,6 +176,7 @@ const readRelayUsers = (params) => read('relay-users', `/internal/chat/moderatio
const readRelayUser = (id) => read('relay-user', `/internal/chat/moderation/relay-users/${Number(id)}`);
const readTtsOverride = (params) => read('tts-override', `/internal/chat/moderation/tts-override${qs(params)}`);
const readSounds = (params) => read('sounds', `/internal/chat/sounds${qs(params)}`);
++const readDmBlockState = (params) => read('dm-block-state', `/internal/chat/dm/block-state${qs(params)}`);
/**
* GET /internal/chat/sounds/by-command — the approved sound a !command plays. Chat answers its own 404 body
* `{ ok: false, error: 'Sound not found' }` for a definitive "no such sound", surfaced as `{ sound: null }`; any
@@@ -213,6 +215,6 @@ module.exports = {
invalidate: (body) => send('invalidate', body),
presence,
readStats, readMessages, readTimeline, readFirstChat, readPendingIp, readRelayUsers, readRelayUser, readTtsOverride,
-- readSounds, readSoundByCommand, soundAsset,
++ readSounds, readSoundByCommand, readDmBlockState, soundAsset,
_setRetryMs(ms) { retryMs = ms || RETRY_MS; },
};
ddiff --git a/server/chat/chat-delivery.js b/server/chat/chat-delivery.js
iindex 0d5cb6c2..0a713257 100644
---- a/server/chat/chat-delivery.js
++++ b/server/chat/chat-delivery.js
@@@ -16,8 +16,6 @@
* logModeration(entry) a moderation-log row (db.logModerationAction shape)
* disconnect({ userId, ip, streamId })
* invalidate(hint, legacy) a cache hint ({ user, user_data, approvals, bans, channel })
-- * mirror(fn, ...args) ingress only: keep Live's own copy of a write Chat also applies (pending IP
-- * rows, hidden relay users, first chats, TTS overrides) that Live still reads
* 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,
@@@ -100,21 +98,6 @@ function invalidate(hint, legacy) {
return legacy ? legacy() : undefined;
}
--// The writes Live keeps on its own read-mirror copy as well (Chat applies them too and mirrors back).
--const MIRROR_WRITES = new Set([
-- 'mergeChatMessageMetadata', 'deleteChatMessage', 'deleteUserChatMessages', 'deleteAnonChatMessages',
-- 'deleteRelayUserMessages', 'deleteChatMessagesByTimeRange', 'reviewPendingIpMessage', 'approveAllFromIp',
-- 'denyAllFromIp', 'recordRelayUser', 'unhideRelayUser', 'unhideRelayUserByIdentity', 'recordFirstChat',
-- 'setTtsVoiceOverride', 'deleteTtsVoiceOverride',
--]);
--
--function mirror(fn, ...args) {
-- try {
-- if (!MIRROR_WRITES.has(fn)) throw new Error('not a mirror write');
-- return db[fn](...args);
-- } catch (err) { console.warn(`[ChatIngress] local ${fn}: ${err.message}`); return undefined; }
--}
--
function after(value, fn) {
return value && typeof value.then === 'function' ? value.then(fn) : fn(value);
}
@@@ -325,7 +308,7 @@ async function resolveAnon(ip) {
}
module.exports = {
-- ingress, message, event, moderate, logModeration, disconnect, disconnectUser, invalidate, mirror, after, client, MIRROR_WRITES,
++ ingress, message, event, moderate, logModeration, disconnect, disconnectUser, invalidate, after, client,
broadcastToStream, broadcastToChannelRoom, broadcastGlobal, broadcastAll, sendDm, sendUserUpdate,
forwardToGlobal, forwardToStreamerRooms, triggerChannelSound, synthesizeAndBroadcastTTS,
init, close, slowModeByStream, getTotalConnections, getStreamViewerCount, getConnectedUserIp, findClientByAnonId,
ddiff --git a/server/chat/chat-reads.js b/server/chat/chat-reads.js
iindex a77f45ee..82606e2f 100644
---- a/server/chat/chat-reads.js
++++ b/server/chat/chat-reads.js
@@@ -2,14 +2,15 @@
/**
* The chat reads Live's own features still make, answered from OpenVibe.Chat (plan T3 J4b:
* "Read Live's chat stats, queues and history from Chat"). Chat owns chat_messages, channel_sounds,
-- * pending_ip_messages, hidden_relay_users and tts_voice_overrides, so Live's home stats, recaps,
-- * AI context, admin console and mod queues read them through Chat's internal read API
-- * (server/chat/chat-client.js). Live's own tables remain as Chat's read mirror until a later step
-- * drops them, so the rollback path and the readers that have not moved yet keep working.
++ * pending_ip_messages, hidden_relay_users, tts_voice_overrides and dm_blocks, so Live's home stats,
++ * 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). Chat's read mirror was retired on 2026-10-05
++ * (Chat #25 removed the sender, Live dropped the receiver), so Live's own copies of the tables Chat
++ * owns are frozen and NO remote answer falls back to them.
*
-- * Mode-aware: when CHAT_AUTHORITY=chat, every answer comes from Chat; otherwise Live runs chat
-- * itself and the same call answers from Live's own tables (the rollback / dev path) — the caller
-- * never has to know which.
++ * Mode-aware: when CHAT_AUTHORITY=chat, every answer comes from Chat; otherwise (dev, drills) the
++ * same call answers from Live's own tables — the caller never has to know which. Unsetting
++ * CHAT_AUTHORITY is not a rollback lever: Live runs no chat server any more either way.
*
* 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
@@@ -545,10 +546,11 @@ function spikeOffsets(streamId, bucketSec = 30, topN = 8, sinceMs) {
}
// ── First chat (the welcome check) ───────────────────────────────────────────
--// Chat owns stream_first_chats; the AI context's "first time chatting here" flag asks Chat's
--// first-chat read. A false answer (they have chatted here) is stable and cached longer; a true
--// answer flips the moment they chat, so it is cached only briefly. The sync caller uses
--// firstChatPeek; on a cold cache or a Chat outage it answers Live's own (mirror-kept) table.
++// Chat owns stream_first_chats; the AI context's "first time chatting here" flag and the relay
++// welcome ask Chat's first-chat read. A false answer (they have chatted here) is stable and cached
++// longer; a true answer flips the moment they chat, so it is cached only briefly. The sync caller
++// uses firstChatPeek. In chat mode a cold cache or a Chat outage answers false — Live's own copy is
++// a frozen pre-retirement snapshot and is never consulted; it answers only when Live runs chat.
const FIRST_CHAT_TTL_FALSE = 5 * 60_000; // has chatted here: stable
const FIRST_CHAT_TTL_TRUE = CACHE_TTL_MS; // first time: flips as soon as they chat
@@@ -569,9 +571,12 @@ function firstChat(channelUserId, identity) {
if (!channelId || !key) return Promise.resolve(false);
return load(`fc:${channelId}:${key}`, firstChatRemote(channelId, key), () => firstChatLocal(key, channelId), {
ttl: FIRST_CHAT_TTL_TRUE, ttlFor: (v) => (v === false ? FIRST_CHAT_TTL_FALSE : FIRST_CHAT_TTL_TRUE),
-- }).then((v) => (v == null ? firstChatLocal(key, channelId) : v === true));
++ }).then((v) => {
++ if (v === true || v === false) return v;
++ return remote() ? false : firstChatLocal(key, channelId); // Chat unreachable: not first, never Live's frozen copy
++ });
}
--/** The synchronous form (the AI persona prompt): the cached answer, else Live's own table. */
++/** The synchronous form (the AI persona prompt): the cached Chat answer, else Live's own table when Live runs chat. */
function firstChatPeek(channelUserId, identity) {
const channelId = Number(channelUserId) || 0;
const key = String(identity || '');
@@@ -579,7 +584,8 @@ function firstChatPeek(channelUserId, identity) {
const v = peek(`fc:${channelId}:${key}`, firstChatRemote(channelId, key), () => firstChatLocal(key, channelId), {
ttl: FIRST_CHAT_TTL_TRUE, ttlFor: (x) => (x === false ? FIRST_CHAT_TTL_FALSE : FIRST_CHAT_TTL_TRUE),
});
-- return (v === true || v === false) ? v : firstChatLocal(key, channelId);
++ if (v === true || v === false) return v;
++ return remote() ? false : firstChatLocal(key, channelId); // Chat unreachable: not first, never Live's frozen copy
}
// ── Moderation queues ─────────────────────────────────────────────────────────
@@@ -627,32 +633,32 @@ function relayUser(id) {
* Is this relay identity hidden (banned) in this channel or site-wide? Synchronous: the relay
* path can carry a message per second and must not wait on an HTTP call.
*
-- * Live's own table OR Chat's list (the #28 rule): a hide the mirror applied wins at once even when a
-- * cached Chat list fetched just before it lacks the row, and a Chat row hides even before the mirror
-- * catches up. A Live-route hide/unhide invalidates the cached list; a cold cache or a Chat outage
-- * falls to Live's own table and fails OPEN, never dropping a relayed line.
++ * Chat owns hidden_relay_users, so in chat mode Chat's list is the only truth: the fresh answer
++ * when there is one, else the last good list while a refresh runs in the background. A cold cache
++ * (Chat has never answered) fails OPEN — never drop a relayed line on a Chat outage — and Live's
++ * own copy (frozen since the mirror's retirement) is never consulted. A Live-route hide/unhide
++ * invalidates the cached list, so the next check re-asks Chat. Live's own table answers only when
++ * Live is not in chat mode.
*/
function isRelayUserHidden(channelId, platform, username) {
const localHidden = () => { try { return !!local().isRelayUserHidden(channelId, platform, username); } catch { return false; } };
if (!remote()) return localHidden();
-- // Live's own table OR Chat's fresh list — the rule agreed with #28. A fresh Chat list alone
-- // would hide a local hide the mirror applied from any other writer (another Live instance, or a
-- // write straight in Chat) for up to CACHE_TTL_MS. A Live-route unhide stays immediate: mod-routes
-- // drops the `hru:` entry on every hide/unhide, so the next check re-asks Chat. Until Chat has
-- // answered (cold cache) or while it is unreachable, Live's own table answers and fails OPEN —
-- // never drop a relayed line on a Chat outage.
const key = `hru:${Number(channelId) || 0}`;
-- const hit = fresh(key);
-- if (hit && Array.isArray(hit.value)) {
-- if (hit.value.some((r) => String(r.platform) === String(platform) && String(r.external_username) === String(username))) return true;
-- return localHidden(); // no fresh Chat match: a local hide still applies
-- }
-- load(key, async () => {
++ const matches = (list) => list.some((r) => String(r.platform) === String(platform) && String(r.external_username) === String(username));
++ const read = async () => {
const out = await client.readRelayUsers({ channel_id: Number(channelId) || undefined, limit: 500 });
if (!out || !Array.isArray(out.relay_users)) throw unavailable('hidden relay users');
return out.relay_users;
-- }, () => []).catch(() => { /* warms the cache for the next check */ });
-- return localHidden();
++ };
++ const hit = fresh(key);
++ if (hit && Array.isArray(hit.value)) return matches(hit.value);
++ const last = kept(key);
++ if (last && Array.isArray(last.value)) {
++ load(key, read, () => []).catch(() => { /* refresh for the next check */ });
++ return matches(last.value); // the last good list, while the refresh runs
++ }
++ load(key, read, () => []).catch(() => { /* warms the cache for the next check */ });
++ return false; // never answered: fail open, never Live's frozen table
}
/** A user's TTS voice override: { voice, pitch, speed, gap } | null. Live, never cached. */
@@@ -667,22 +673,17 @@ function ttsOverride(identityKey) {
}
/**
* The TTS engine's synchronous read: the last good override while Chat refreshes in the background.
-- * On a cache miss or a Chat failure it answers Live's own mirror table (which Chat keeps current),
-- * so the first line after a restart, an eviction or during a Chat outage is still spoken with the
-- * override rather than the auto voice — the same fallback `isRelayUserHidden` uses.
++ * In chat mode a cache miss or a Chat failure answers null (the auto voice) — Live's own copy is a
++ * frozen pre-retirement snapshot and is never consulted; it answers only when Live runs chat.
*/
function ttsOverridePeek(identityKey) {
const k = String(identityKey || '').trim().toLowerCase();
if (!k) return null;
-- const localOverride = () => local().getTtsVoiceOverride(k);
-- const got = peek(`ttsp:${k}`, async () => {
++ return peek(`ttsp:${k}`, async () => {
const out = await client.readTtsOverride({ identity_key: k });
if (!out) throw unavailable('tts override');
return out.tts_override || null;
-- }, localOverride);
-- if (got) return got;
-- if (!remote()) return null; // peek already answered from Live's own table
-- try { return localOverride(); } catch { return null; }
++ }, () => local().getTtsVoiceOverride(k));
}
// ── Channel sounds ────────────────────────────────────────────────────────────
@@@ -703,32 +704,56 @@ function soundCount(ownerId) {
const normalizeCommand = (command) => String(command || '').trim().toLowerCase().replace(/^!+/, '');
async function remoteSoundByCommand(ownerId, command) {
const out = await client.readSoundByCommand({ channel_id: ownerId, command });
-- if (out == null) throw unavailable('sound by command'); // unreachable: keep / fall back
++ if (out == null) throw unavailable('sound by command'); // unreachable: keep the last good answer
return out.sound || false; // the sound, or Chat's definitive none
}
--/** The approved sound a !command plays (or null); Chat owns the row, Live's table is the fallback. */
++/** The approved sound a !command plays (or null). Chat owns the row in chat mode; Live's own table answers only when Live runs chat. */
function soundByCommand(ownerId, command) {
const id = Number(ownerId) || 0;
const cmd = normalizeCommand(command);
if (!id || !cmd) return Promise.resolve(null);
const localFn = () => local().getChannelSoundByCommand(id, cmd) || null;
-- return load(`sbc:${id}:${cmd}`, () => remoteSoundByCommand(id, cmd), localFn).then((v) => {
-- if (v === false) return null; // Chat answered: no such sound
-- if (v) return v;
-- return safeLocal(localFn, null); // cold / outage: Live's own table
-- });
++ return load(`sbc:${id}:${cmd}`, () => remoteSoundByCommand(id, cmd), localFn)
++ .then((v) => (v && v !== false ? v : null)); // false / null: Chat's none, or unreachable — never Live's frozen row
}
--/** The synchronous form (the RobotStreamer !sound lookup): cached answer, else Live's own table. */
++/** The synchronous form (the RobotStreamer !sound lookup): the cached Chat answer, else null. */
function soundByCommandPeek(ownerId, command) {
const id = Number(ownerId) || 0;
const cmd = normalizeCommand(command);
if (!id || !cmd) return null;
-- const localFn = () => local().getChannelSoundByCommand(id, cmd) || null;
-- const v = peek(`sbc:${id}:${cmd}`, () => remoteSoundByCommand(id, cmd), localFn);
-- if (v) return v;
-- if (v === false) return null; // Chat answered: no such sound
-- if (!remote()) return null; // the peek already answered from Live's own table
-- return safeLocal(localFn, null); // cold / Chat unreachable: Live's mirror-filled table
++ const v = peek(`sbc:${id}:${cmd}`, () => remoteSoundByCommand(id, cmd), () => local().getChannelSoundByCommand(id, cmd) || null);
++ return v && v !== false ? v : null;
++}
++
++// ── DM blocks (the call-invite gate) ─────────────────────────────────────────
++const DM_BLOCK_TTL_FALSE = 30_000; // not blocked: blocks are rare, 30 s is plenty
++const DM_BLOCK_TTL_TRUE = 5_000; // blocked: an unblock should lift a ring quickly
++/**
++ * Has either of these users blocked the other? Chat owns dm_blocks, so chat mode asks Chat's
++ * block-state read (chat.messages.read); Live's own dm.js table answers only when Live is not in
++ * chat mode. Cached briefly (30 s when not blocked, 5 s when blocked; nothing invalidates it —
++ * blocks are rare).
++ *
++ * `strict`: past the cache and with Chat unreachable the answer is null, never the last good
++ * value, and the call-invite route fails closed on it — refusing a ring is far better than ringing
++ * someone who blocked the caller.
++ */
++function dmBlocked(a, b) {
++ const x = Number(a) || 0;
++ const y = Number(b) || 0;
++ if (!x || !y) return Promise.resolve(null);
++ const key = `dmb:${Math.min(x, y)}:${Math.max(x, y)}`;
++ return load(key, async () => {
++ const out = await client.readDmBlockState({ a: x, b: y });
++ if (!out || typeof out.blocked !== 'boolean') throw unavailable('dm block state');
++ return out.blocked;
++ }, () => safeLocal(() => require('./dm').isBlockedEither(x, y), null), {
++ strict: true,
++ ttlFor: (v) => (v === true ? DM_BLOCK_TTL_TRUE : DM_BLOCK_TTL_FALSE),
++ }).catch((err) => {
++ if (err && err.unavailable) return null;
++ throw err;
++ });
}
/**
@@@ -783,6 +808,6 @@ module.exports = {
messageById, channelMessages, channelMessagesPeek, userHistory, relayHistory, channelSamples, searchMessages,
liveChatBuckets, recentChatText, spikeOffsets, firstChat, firstChatPeek,
pendingIp, relayUsers, relayUser, ttsOverride, ttsOverridePeek, isRelayUserHidden,
-- soundCount, soundByCommand, soundByCommandPeek, pendingSounds, recordSoundAsset, invalidate,
++ soundCount, soundByCommand, soundByCommandPeek, dmBlocked, pendingSounds, recordSoundAsset, invalidate,
_reset, _age, _size, _cacheMax, CACHE_TTL_MS, CACHE_KEEP_MS,
};
ddiff --git a/server/chat/live-context-routes.js b/server/chat/live-context-routes.js
iindex a7edd6aa..f2b00aa1 100644
---- a/server/chat/live-context-routes.js
++++ b/server/chat/live-context-routes.js
@@@ -26,7 +26,6 @@
* 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
-- * mirror (capability live.chat_mirror.write) — Chat's changes to its tables, applied to Live's copy
*
* Every effect that acts for a person re-checks that person here (moderator, owner, admin) — Chat
* checked first; Live does not take its word for it.
@@@ -258,11 +257,7 @@ contextRouter.get('/tts-audio/:file', (req, res) => {
// ── Effects ────────────────────────────────────────────────────
--effectsRouter.post('/mirror', guard('live.chat_mirror.write'), (req, res, next) => next());
--effectsRouter.use((req, res, next) => {
-- if (req.path === '/mirror') return next();
-- return guard('live.chat_effects.write')(req, res, next);
--});
++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) => {
@@@ -627,84 +622,19 @@ effectsRouter.post('/notify/dm-read', (req, res) => {
res.json({ ok: true });
});
--// Channel sound files are mirrored to OpenVibe.Media from Live's copy of channel_sounds.
++// Channel sound files are mirrored to OpenVibe.Media from Chat's channel_sounds (Chat owns the
++// rows since T3). Chat sends the Media asset id it holds on `asset_id`; Live's own channel_sounds
++// copy is a frozen pre-retirement snapshot and is never read for it.
effectsRouter.post('/asset-sync', (req, res) => {
try {
const sync = require('../media-proxy/asset-sync');
if (req.body?.op === 'syncSoon') sync.syncSoon();
else if (req.body?.op === 'remove-sound') {
-- const row = db.get('SELECT media_asset_id FROM channel_sounds WHERE id = ?', [int(req.body.asset_id)]);
-- if (row && row.media_asset_id) sync.removeAsset(row.media_asset_id);
++ const assetId = int(req.body.media_asset_id != null ? req.body.media_asset_id : req.body.asset_id);
++ if (assetId) sync.removeAsset(assetId);
}
} catch { /* best-effort */ }
res.json({ ok: true });
});
--// ── The read mirror of Chat's tables ──────────────────────────────
--// Same tables, same ids. Upserts set only the columns Live's table has (Chat's *subject_id
--// columns are skipped; Live-only columns such as channel_sounds.media_asset_id are kept).
--// The six staged tables (roadmap C-04) are Chat's now and are not mirrored here; only the C-02
--// twelve below still are.
--const MIRROR_TABLES = {
-- chat_messages: ['id'],
-- dm_conversations: ['id'],
-- dm_participants: ['id'],
-- dm_messages: ['id'],
-- dm_blocks: ['id'],
-- tts_voice_overrides: ['identity_key'],
-- channel_sounds: ['id'],
-- relay_users: ['platform', 'username'],
-- hidden_relay_users: ['id'],
-- pending_ip_messages: ['id'],
-- stream_first_chats: ['chatter_key', 'channel_user_id'],
-- moderation_actions: ['id'],
--};
--const _cols = new Map();
--function liveColumns(table) {
-- if (!_cols.has(table)) _cols.set(table, new Set(db.getDb().prepare(`PRAGMA table_info(${table})`).all().map((c) => c.name)));
-- return _cols.get(table);
--}
--
--function applyMirror(changes) {
-- const d = db.getDb();
-- let applied = 0;
-- const skipped = [];
-- // A copy of the authority's rows: foreign keys are the authority's business. Synchronous, so
-- // nothing else runs while they are off.
-- d.pragma('foreign_keys = OFF');
-- try {
-- d.transaction(() => {
-- for (const c of changes) {
-- const pk = MIRROR_TABLES[c && c.table];
-- if (!pk) { skipped.push({ table: c && c.table, reason: 'not a mirrored table' }); continue; }
-- const have = liveColumns(c.table);
-- try {
-- if (c.op === 'delete') {
-- d.prepare(`DELETE FROM ${c.table} WHERE ${pk.map((k) => `${k} = ?`).join(' AND ')}`).run(...pk.map((k) => c.pk[k]));
-- } else if (c.op === 'upsert' && c.row) {
-- const cols = Object.keys(c.row).filter((k) => have.has(k) && /^[a-z_]+$/.test(k));
-- const upd = cols.filter((k) => !pk.includes(k));
-- d.prepare(`INSERT INTO ${c.table} (${cols.join(', ')}) VALUES (${cols.map(() => '?').join(', ')})
-- ON CONFLICT(${pk.join(', ')}) DO ${upd.length ? `UPDATE SET ${upd.map((k) => `${k} = excluded.${k}`).join(', ')}` : 'NOTHING'}`)
-- .run(...cols.map((k) => c.row[k]));
-- } else { skipped.push({ table: c.table, reason: 'bad change' }); continue; }
-- applied++;
-- } catch (err) {
-- skipped.push({ table: c.table, pk: c.pk || (c.row && pk.map((k) => c.row[k])), reason: err.message });
-- }
-- }
-- })();
-- } finally {
-- d.pragma('foreign_keys = ON');
-- }
-- return { applied, skipped };
--}
--
--effectsRouter.post('/mirror', (req, res) => {
-- const changes = Array.isArray(req.body?.changes) ? req.body.changes.slice(0, 1000) : [];
-- const out = applyMirror(changes);
-- if (out.skipped.length) console.warn(`[ChatMirror] ${out.skipped.length} change(s) not applied:`, JSON.stringify(out.skipped.slice(0, 3)));
-- res.json({ ok: true, ...out });
--});
--
--module.exports = { contextRouter, effectsRouter, applyMirror, userProjection, MIRROR_TABLES };
++module.exports = { contextRouter, effectsRouter, userProjection };
ddiff --git a/server/integrations/chat-relay-service.js b/server/integrations/chat-relay-service.js
iindex 2d85a26e..1c50330a 100644
---- a/server/integrations/chat-relay-service.js
++++ b/server/integrations/chat-relay-service.js
@@@ -24,6 +24,11 @@ const chatReads = require('../chat/chat-reads');
const RECONNECT_BASE_MS = 5000;
const RECONNECT_MAX_MS = 60000;
++// Chatters this process has already welcomed (`<channel user id>:ext:<prefixed username>`), so a
++// second line arriving before Chat's first-chat answer reflects the first is not welcomed twice.
++const welcomedRelayUsers = new Set();
++const WELCOMED_MAX = 5000;
++
// Platform-specific colors for chat display
const PLATFORM_COLORS = {
twitch: '#9146ff',
@@@ -387,8 +392,9 @@ class ChatRelayService {
const prefixedUsername = `[${label}] ${username}`;
// Check if this relay user is hidden/banned. Chat's queue answers in chat mode (a cached
-- // peek — this path must not wait on an HTTP call), Live's own mirror-fed table otherwise;
-- // Chat unreachable falls back to that table, so a Chat outage never un-hides anyone.
++ // peek — this path must not wait on an HTTP call): the fresh list, else the last good one.
++ // A cold cache fails open, so a Chat outage never drops relayed lines; Live's own frozen
++ // table is consulted only when Live is not in chat mode.
try {
const stream = db.getStreamById(bridge.streamId);
const channel = stream?.channel_id ? db.getChannelById(stream.channel_id) : (stream ? db.getChannelByUserId(stream.user_id) : null);
@@@ -398,7 +404,11 @@ class ChatRelayService {
} catch { /* non-critical — allow message through on error */ }
const delivery = require('../chat/chat-delivery');
-- if (delivery.ingress()) return this._deliverToChat(delivery, bridge, username, prefixedUsername, color, extras, String(message || ''));
++ if (delivery.ingress()) {
++ // Not awaited: the relay socket hands lines in sequence, and the welcome check ahead of
++ // the send must not stall it. The catch keeps a surprise rejection off the process.
++ return this._deliverToChat(delivery, bridge, username, prefixedUsername, color, extras, String(message || '')).catch(() => { /* logged in chat-reads / chat-client */ });
++ }
// Record this relay user's activity; their first message becomes a join date.
try { db.recordRelayUser(bridge.platform, username); } catch { /* non-critical */ }
@@@ -496,14 +506,25 @@ class ChatRelayService {
} catch { /* non-critical */ }
}
-- /** Chat ingress: Chat persists, broadcasts, mirrors, reads aloud and records the first chat in one call. */
-- _deliverToChat(delivery, bridge, username, prefixedUsername, color, extras, message) {
++ /**
++ * Chat ingress: Chat persists, broadcasts, reads aloud and records the first chat in one call.
++ * The welcome decision asks Chat's first-chat read BEFORE the message send — Chat records the
++ * first chat on that call, so a decision after it would never see `first` — and never Live's
++ * own pre-retirement copy. A second line within Chat's short "first" cache window is deduped
++ * in-process; the cached answer is dropped after the send so the next check sees Chat's record.
++ */
++ async _deliverToChat(delivery, bridge, username, prefixedUsername, color, extras, message) {
let first = false;
++ let welcomeKey = null;
try {
const stream = db.getStreamById(bridge.streamId);
-- first = !!(stream?.user_id && db.isFirstChatInChannel(`ext:${prefixedUsername}`, stream.user_id));
-- } catch { /* non-critical */ }
-- delivery.mirror('recordRelayUser', bridge.platform, username);
++ const chatterKey = `ext:${prefixedUsername}`;
++ if (stream?.user_id) {
++ welcomeKey = `${stream.user_id}:${chatterKey}`;
++ first = await chatReads.firstChat(stream.user_id, chatterKey);
++ if (first && welcomedRelayUsers.has(welcomeKey)) first = false;
++ }
++ } catch { /* non-critical — no welcome rather than a wrong one */ }
delivery.moderate('relay-record', { platform: bridge.platform, username });
delivery.after(delivery.message({
stream_id: bridge.streamId, username: prefixedUsername, message, message_type: 'chat', is_global: false,
@@@ -512,8 +533,9 @@ class ChatRelayService {
tts: { identity_key: `${bridge.platform}:${prefixedUsername}` },
}), (id) => this._relayFollowUps(bridge, username, prefixedUsername, message, extras, id));
if (first) {
-- // Chat records the first chat in its copy; Live's copy decides the next welcome.
-- try { delivery.mirror('recordFirstChat', `ext:${prefixedUsername}`, db.getStreamById(bridge.streamId).user_id); } catch { /* */ }
++ if (welcomedRelayUsers.size >= WELCOMED_MAX) welcomedRelayUsers.clear();
++ welcomedRelayUsers.add(welcomeKey);
++ chatReads.invalidate(`fc:${welcomeKey}`); // Chat's cached `first` answer is about to be stale
delivery.event({ kind: 'stream', id: bridge.streamId }, {
type: 'system',
message: `Welcome ${username} from ${PLATFORM_LABELS[bridge.platform] || bridge.platform}! 👋`,
ddiff --git a/server/integrations/robotstreamer-service.js b/server/integrations/robotstreamer-service.js
iindex 1fdf2421..f9f73dcd 100644
---- a/server/integrations/robotstreamer-service.js
++++ b/server/integrations/robotstreamer-service.js
@@@ -655,7 +655,8 @@ class RobotStreamerService {
const username = `[RS] ${rawUsername}`;
// Check if this relay user is hidden/banned: Chat's queue in chat mode (a cached
-- // peek), Live's own mirror-fed table otherwise; Chat unreachable falls back to it.
++ // peek — fresh list, else the last good one; a cold cache fails open), Live's own
++ // table only when Live is not in chat mode.
try {
const channel = stream.channel_id
? db.getChannelById(stream.channel_id)
@@@ -667,7 +668,7 @@ class RobotStreamerService {
// Record this relay user (first message = join date) so RobotStreamer
// chatters get the same chat logs + AI insight as other relay users.
-- if (delivery.ingress()) { delivery.mirror('recordRelayUser', 'rs', rawUsername); delivery.moderate('relay-record', { platform: 'rs', username: rawUsername }); }
++ if (delivery.ingress()) { delivery.moderate('relay-record', { platform: 'rs', username: rawUsername }); }
else { try { db.recordRelayUser('rs', rawUsername); } catch { /* non-critical */ } }
// Let RobotStreamer viewers trigger channel !sound commands too. If the
@@@ -677,8 +678,8 @@ class RobotStreamerService {
if (trimmed.startsWith('!')) {
const parts = trimmed.split(/\s+/);
const scmd = parts[0].slice(1).toLowerCase();
-- // Chat owns channel_sounds; ask its by-command read (a cached peek), which falls
-- // back to Live's own table while Chat is unreachable.
++ // Chat owns channel_sounds; ask its by-command read (a cached peek). Live's own
++ // frozen table is never a fallback — null means no sound (or Chat unreachable).
if (scmd && chatReads.soundByCommandPeek(stream.user_id, scmd)) {
if (delivery.ingress()) {
delivery.event({ kind: 'stream', id: stream.id }, {
ddiff --git a/server/streaming/routes.js b/server/streaming/routes.js
iindex d8aac55d..669332a3 100644
---- a/server/streaming/routes.js
++++ b/server/streaming/routes.js
@@@ -1368,7 +1368,7 @@ router.delete('/voice-channels/:channelId', requireAuth, (req, res) => {
});
const _callUserRate = new Map(); // userId → [timestamps]
--router.post('/voice-channels/call-user', requireAuth, (req, res) => {
++router.post('/voice-channels/call-user', requireAuth, async (req, res) => {
try {
// Six calls a minute per account: a ring is a notification on someone else's screen.
const now = Date.now();
@@@ -1383,7 +1383,15 @@ router.post('/voice-channels/call-user', requireAuth, (req, res) => {
if (!targetUser && targetUsername) targetUser = db.getUserByUsername(targetUsername);
if (!targetUser) return res.status(404).json({ error: 'User not found' });
if (targetUser.id === req.user.id) return res.status(400).json({ error: 'You cannot call yourself' });
-- try { const dm = require('../chat/dm'); if (dm.isBlockedEither && dm.isBlockedEither(req.user.id, targetUser.id)) return res.status(403).json({ error: 'You cannot call this user' }); } catch { /* */ }
++ // Blocks live in Chat (dm_blocks is Chat's table), so the check goes through chat-reads. On an
++ // unverifiable answer — a stale cache with Chat unreachable — the ring is REFUSED: ringing
++ // someone who blocked the caller is the worse failure, and 'try again shortly' is honest.
++ const blocked = await chatReads.dmBlocked(req.user.id, targetUser.id).catch(() => null);
++ if (blocked !== false) {
++ return blocked === true
++ ? res.status(403).json({ error: 'You cannot call this user' })
++ : res.status(503).json({ error: 'Could not check the block list — try again shortly' });
++ }
// Reuse caller's existing temp channel if present; otherwise create a private one — a
// 1:1 call is not something the whole site should see listed and be able to walk into.
ddiff --git a/test/chat-context.test.js b/test/chat-context.test.js
iindex dced92ca..0d50a271 100644
---- a/test/chat-context.test.js
++++ b/test/chat-context.test.js
@@@ -37,7 +37,6 @@ function serviceToken(cap, { aud = 'openvibe.live', sub = 'svc:chat' } = {}) {
}
const READ = serviceToken(['live.chat_context.read']);
const WRITE = serviceToken(['live.chat_effects.write']);
--const MIRROR = serviceToken(['live.chat_mirror.write']);
// Stub Network (Live's own service token for audience openvibe.chat) and stub Chat (its typed ingress).
const ingressCalls = [];
@@@ -48,7 +47,7 @@ const network = http.createServer((req, res) => {
res.setHeader('Content-Type', 'application/json');
if (req.url === '/oauth/token') {
const f = new URLSearchParams(raw);
-- return res.end(JSON.stringify({ access_token: serviceToken(['chat.message.send', 'chat.event.publish', 'chat.moderation.write', 'chat.cache.invalidate', 'chat.presence.read'], { aud: f.get('audience'), sub: 'svc:live' }), expires_in: 300 }));
++ return res.end(JSON.stringify({ access_token: serviceToken(['chat.message.send', 'chat.event.publish', 'chat.moderation.write', 'chat.cache.invalidate', 'chat.presence.read', 'chat.messages.read'], { aud: f.get('audience'), sub: 'svc:live' }), expires_in: 300 }));
}
res.statusCode = 404; res.end('{}');
});
@@@ -56,14 +55,15 @@ const network = http.createServer((req, res) => {
// Chat's internal read API (roadmap T3 J4b): Live reads stats, queues, history and sounds through
// these. The stub answers from Live's own tables (emulating Chat's copy) so the seeds apply, and
// `readState.down` fails every read the way a Chat outage does.
--const readState = { down: false, soundAssetDown: false, relayOnly: [], msgById: 0, firstChatCalls: 0 };
++const readState = { down: false, soundAssetDown: false, relayOnly: [], msgById: 0, firstChatCalls: 0, dmBlocked: false, blockStateDown: false };
function readReply(req, res, raw) {
const url = String(req.url);
const path = url.split('?')[0];
const q = new URLSearchParams(url.split('?')[1] || '');
const isRead = path === '/internal/chat/stats' || path === '/internal/chat/messages' || path === '/internal/chat/first-chat'
|| path.startsWith('/internal/chat/moderation/') || path === '/internal/chat/sounds'
-- || path === '/internal/chat/sounds/by-command' || path === '/internal/chat/sounds/asset';
++ || path === '/internal/chat/sounds/by-command' || path === '/internal/chat/sounds/asset'
++ || path === '/internal/chat/dm/block-state';
if (!isRead) return false;
if (readState.down) { res.statusCode = 503; res.end(JSON.stringify({ ok: false, error: 'Chat read unavailable' })); return true; }
if (path === '/internal/chat/sounds/asset' && readState.soundAssetDown) { res.statusCode = 503; res.end(JSON.stringify({ ok: false, error: 'Chat sound write unavailable' })); return true; }
@@@ -101,6 +101,10 @@ function readReply(req, res, raw) {
const r = d2.get(`SELECT COUNT(*) AS messages, COUNT(DISTINCT ${CHATTER}) AS chatters FROM chat_messages WHERE ${where.join(' AND ')}`, params);
return ok({ messages: Number(r.messages), chatters: Number(r.chatters) });
}
++ if (path === '/internal/chat/dm/block-state') {
++ if (readState.blockStateDown) { res.statusCode = 503; res.end(JSON.stringify({ ok: false, error: 'Chat read unavailable' })); return true; }
++ return ok({ blocked: !!readState.dmBlocked });
++ }
if (path === '/internal/chat/first-chat') {
readState.firstChatCalls++;
const r = d2.get('SELECT 1 AS present FROM stream_first_chats WHERE chatter_key = ? AND channel_user_id = ?', [String(q.get('identity')), Number(q.get('channel_id'))]);
@@@ -211,6 +215,11 @@ const chat = http.createServer((req, res) => {
const listen = (s) => new Promise((r) => s.listen(0, '127.0.0.1', () => r(s.address().port)));
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
++const waitFor = async (pred, what, ms = 3000) => {
++ const end = Date.now() + ms;
++ while (Date.now() < end) { const v = pred(); if (v) return v; await sleep(10); }
++ throw new Error(`timed out waiting for ${what}`);
++};
(async () => {
process.env.OV_NETWORK_INTERNAL_URL = `http://127.0.0.1:${await listen(network)}`;
@@@ -268,6 +277,7 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
app.use('/internal/chat-context', routes.contextRouter);
app.use('/internal/chat-effects', routes.effectsRouter);
app.use('/api/mod', require('../server/admin/mod-routes'));
++ app.use('/api/streams', require('../server/streaming/routes'));
const port = await listen(http.createServer(app));
const call = async (method, p, { token, body, headers = {} } = {}) => {
const res = await fetch(`http://127.0.0.1:${port}${p}`, {
@@@ -400,25 +410,6 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
[viewer, 'VC_CALL_INVITE', 'STREAMER is calling you', 'Join voice channel: STREAMER\'s call', streamer, 'streamer']);
assert.ok(pushed[0].url.endsWith(`/?vcInvite=${encodeURIComponent(`user-${streamer}-x`)}`));
-- // 9. The read mirror: same ids, Chat-only columns ignored, Live-only columns kept. Chat's
-- // LIVE_MIRROR is on in production, so the tables it keeps current stay Live's until the
-- // later step that removes both the mirror and its remaining readers.
-- const mirror = (changes) => call('POST', '/internal/chat-effects/mirror', { token: MIRROR, body: { changes } });
-- assert.strictEqual((await call('POST', '/internal/chat-effects/mirror', { token: WRITE, body: { changes: [] } })).status, 403, 'mirror has its own capability');
-- let m = await mirror([{ table: 'chat_messages', op: 'upsert', row: { id: 900001, stream_id: streamId, channel_user_id: streamer, user_id: viewer, username: 'VIEWER', message: 'hi from chat', message_type: 'chat', is_global: 0, is_deleted: 0, timestamp: '2026-09-22 10:00:00', subject_id: 'usr_01J9ZZZZZZZZZZZZZZZZZZZZZZ' } }]);
-- assert.strictEqual(m.body.applied, 1);
-- assert.strictEqual(db.getChatMessageById(900001).message, 'hi from chat');
-- // Columns only Live's copy has (media-proxy/asset-sync adds them at start).
-- for (const c of ['media_url TEXT', 'media_asset_id INTEGER']) { try { d.exec(`ALTER TABLE channel_sounds ADD COLUMN ${c}`); } catch { /* present */ } }
-- d.prepare("INSERT INTO channel_sounds (id, channel_owner_id, command, url, media_asset_id) VALUES (77, ?, 'honk', '/x.mp3', 555)").run(streamer);
-- m = await mirror([{ table: 'channel_sounds', op: 'upsert', row: { id: 77, channel_owner_id: streamer, command: 'honk2', url: '/x.mp3', created_by_subject_id: null } }, { table: 'chat_messages', op: 'delete', pk: { id: 900001 } }, { table: 'users', op: 'delete', pk: { id: viewer } }]);
-- assert.strictEqual(m.body.applied, 2);
-- assert.strictEqual(m.body.skipped.length, 1, 'only chat tables are mirrored');
-- const s77 = d.prepare('SELECT command, media_asset_id FROM channel_sounds WHERE id = 77').get();
-- assert.deepStrictEqual(s77, { command: 'honk2', media_asset_id: 555 });
-- assert.strictEqual(db.getChatMessageById(900001), undefined);
-- assert.ok(db.getUserById(viewer), 'users are never touched');
--
// 10. The chat seam Live's modules get (T3 J2: Live runs no chat server — the bridge, its
// outbox and the local ChatServer are gone; chat-delivery carries the push/presence surface).
const chatDelivery = require('../server/chat/chat-delivery');
@@@ -483,8 +474,8 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
assert.ok(chatReads.siteStatsPeek(), 'the background warm-up answered');
// (2)(4) A TTS override survives a TTL and a Chat outage (the engine reads it
-- // synchronously); a cold peek answers Live's own mirror table, not the auto voice.
-- assert.strictEqual(chatReads.ttsOverridePeek('user:viewer').voice, 'en+f3', 'a cold peek falls back to Live\'s own table');
++ // synchronously); in chat mode a cold peek answers null, never Live's frozen copy.
++ assert.strictEqual(chatReads.ttsOverridePeek('user:viewer'), null, 'a cold peek answers null, never Live\'s frozen table');
for (let i = 0; i < 300 && !chatReads.ttsOverridePeek('user:viewer'); i++) await sleep(10);
assert.strictEqual(chatReads.ttsOverridePeek('user:viewer').voice, 'en+f3');
chatReads._age(chatReads.CACHE_TTL_MS + 1000, 'ttsp:');
@@@ -530,17 +521,19 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const cs = await chatReads.channelSamples(streamer, { relay: { platform: 'twitch', rawUsername: 'alice' }, limit: 10 });
assert.strictEqual(cs.length, 1, 'a relay clone sample finds the prefixed row');
-- // (1) Hidden relay users: Live's mirror row hides at once; Chat's queue hides even when
-- // the mirror has not seen it; a Chat outage fails open to Live's own table.
-- assert.strictEqual(chatReads.isRelayUserHidden(channel.id, 'twitch', 'alice'), true, 'the mirror row hides at once');
++ // (1) Hidden relay users come from Chat's list only in chat mode: a cold cache fails
++ // open and warms in the background, and a Chat outage answers the last good list.
++ chatReads._reset();
++ assert.strictEqual(chatReads.isRelayUserHidden(channel.id, 'twitch', 'alice'), false, 'a cold cache fails open');
readState.relayOnly = [{ id: 999999, channel_id: channel.id, platform: 'youtube', external_username: 'ghost' }];
assert.strictEqual(chatReads.isRelayUserHidden(channel.id, 'youtube', 'ghost'), false, 'the first check warms Chat in the background');
for (let i = 0; i < 300 && !chatReads.isRelayUserHidden(channel.id, 'youtube', 'ghost'); i++) await sleep(10);
-- assert.strictEqual(chatReads.isRelayUserHidden(channel.id, 'youtube', 'ghost'), true, "Chat's queue hides a user Live's mirror has not seen");
++ assert.strictEqual(chatReads.isRelayUserHidden(channel.id, 'youtube', 'ghost'), true, "Chat's queue hides a user");
++ assert.strictEqual(chatReads.isRelayUserHidden(channel.id, 'twitch', 'alice'), true, "Chat's copy carries alice (the stub reads the same table)");
readState.down = true;
chatReads._age(chatReads.CACHE_TTL_MS + 1000, 'hru:');
-- assert.strictEqual(chatReads.isRelayUserHidden(channel.id, 'youtube', 'ghost'), false, "Chat unreachable fails open to Live's own table");
-- assert.strictEqual(chatReads.isRelayUserHidden(channel.id, 'twitch', 'alice'), true, "Live's own hidden row still applies");
++ assert.strictEqual(chatReads.isRelayUserHidden(channel.id, 'youtube', 'ghost'), true, 'Chat unreachable answers the last good list');
++ assert.strictEqual(chatReads.isRelayUserHidden(channel.id, 'twitch', 'nobody'), false, 'a name no list carries stays unhidden');
readState.down = false;
readState.relayOnly = [];
chatReads._reset();
@@@ -627,9 +620,10 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
chatReads._reset();
}
-- // (4) A cold or outage peek answers Live's own override table, not the auto voice.
++ // (4) In chat mode a cold or outage peek answers null, never Live's frozen override table.
++ chatReads._reset();
readState.down = true;
-- assert.strictEqual(chatReads.ttsOverridePeek('user:viewer').voice, 'en+f3', 'a peek falls back to Live\'s own table');
++ assert.strictEqual(chatReads.ttsOverridePeek('user:viewer'), null, 'a peek answers null, never Live\'s frozen table');
readState.down = false;
chatReads._reset();
@@@ -741,8 +735,8 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
chatReads._reset();
}
-- // (3) Live's own hidden row OR Chat's fresh list — a local hide the mirror applied still
-- // hides even when a fresh Chat list lacks it (the #28 rule, restored).
++ // (3) Live's own hidden copy is frozen and dead weight in chat mode: a stale local hide
++ // that Chat's list does not carry must not hide anyone (the mirror no longer keeps it in step).
{
const chatClient = require('../server/chat/chat-client');
const realReadRelayUsers = chatClient.readRelayUsers;
@@@ -752,11 +746,13 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
chatClient.readRelayUsers = async () => ({ relay_users: [] }); // Chat's list holds nothing
chatReads._reset();
try {
-- d2.run('DELETE FROM hidden_relay_users WHERE channel_id = ?', [freshCh]);
-- chatReads.isRelayUserHidden(freshCh, 'twitch', 'nobody'); // warm Chat's fresh (empty) list
-- for (let i = 0; i < 100; i++) await sleep(10);
++ assert.strictEqual(d2.isRelayUserHidden(freshCh, 'twitch', 'localonly'), false, 'no local row yet');
d2.run("INSERT INTO hidden_relay_users (channel_id, platform, external_username, action, created_by) VALUES (?, 'twitch', 'localonly', 'hide', ?)", [freshCh, streamer]);
-- assert.strictEqual(chatReads.isRelayUserHidden(freshCh, 'twitch', 'localonly'), true, "Live's own hidden row wins over a fresh Chat list that lacks it");
++ assert.strictEqual(d2.isRelayUserHidden(freshCh, 'twitch', 'localonly'), true, "Live's own row is there");
++ assert.strictEqual(chatReads.isRelayUserHidden(freshCh, 'twitch', 'localonly'), false, 'a cold cache fails open, never Live\'s row');
++ for (let i = 0; i < 300 && chatReads._size() === 0; i++) await sleep(10);
++ assert.ok(chatReads._size() > 0, 'the background read warmed the cache');
++ assert.strictEqual(chatReads.isRelayUserHidden(freshCh, 'twitch', 'localonly'), false, "Live's stale local hide is ignored — Chat's list is the truth");
} finally {
chatClient.readRelayUsers = realReadRelayUsers;
d2.run('DELETE FROM hidden_relay_users WHERE channel_id = ?', [freshCh]);
@@@ -869,50 +865,168 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
await chatReads.firstChat(streamer, `user:${viewer}`);
assert.strictEqual(readState.firstChatCalls, 1, 'a true answer is re-asked after the short TTL');
chatReads._reset();
-- d2.run('INSERT INTO stream_first_chats (chatter_key, channel_user_id) VALUES (?, ?)', [`user:${viewer}`, streamer]);
++ d2.run('DELETE FROM stream_first_chats WHERE chatter_key = ?', [`user:${viewer}`]);
++ assert.strictEqual(d2.isFirstChatInChannel(`user:${viewer}`, streamer), true, 'Live\'s frozen table would say first');
readState.down = true;
-- assert.strictEqual(chatReads.firstChatPeek(streamer, `user:${viewer}`), false, 'a cold + down peek falls to Live\'s own table');
++ assert.strictEqual(chatReads.firstChatPeek(streamer, `user:${viewer}`), false, 'a cold + down peek answers not-first, never Live\'s frozen table');
readState.down = false;
chatReads._reset();
// The AI context passes `user:<user_id>`, never the username: a row keyed by the numeric id
// suppresses the welcome flag, where the old `user:<username>` key would have missed it.
++ // Its peek is synchronous, so each case warms Chat's answer first.
d2.run("INSERT INTO chat_messages (user_id, username, message, message_type, channel_user_id, timestamp) VALUES (?, 'viewer', 'hi there', 'chat', ?, ?)", [viewer, streamer, sqlNow()]);
await chatReads.channelMessages(streamer, 40);
const context = require('../server/ai/context');
const ctxStream = db.getStreamById(streamId);
const greet = { remember_viewers: true, greet_first_timers: true, max_open_threads: 3, hear_enabled: false };
++ d2.run('INSERT INTO stream_first_chats (chatter_key, channel_user_id) VALUES (?, ?)', [`user:${viewer}`, streamer]);
chatReads.invalidate('fc:');
++ await chatReads.firstChat(streamer, `user:${viewer}`); // warm: the context's peek cannot await
let tail = context.volatileTail({ userId: streamer, stream: ctxStream, settings: greet, sinceChatId: 0, botNames: new Set() });
assert.ok(!/first time chatting here/.test(tail.text), 'a user with a recorded first chat is not greeted again');
d2.run('DELETE FROM stream_first_chats WHERE chatter_key = ?', [`user:${viewer}`]);
chatReads.invalidate('fc:');
++ await chatReads.firstChat(streamer, `user:${viewer}`);
tail = context.volatileTail({ userId: streamer, stream: ctxStream, settings: greet, sinceChatId: 0, botNames: new Set() });
assert.ok(/first time chatting here/.test(tail.text), 'a new user is flagged first time (user:<id> identity)');
chatReads._reset();
-- // ── Sounds by command: Chat owns the row; a 404 is authoritative; a cold/down peek falls
-- // to Live's own table. ──
++ // ── Sounds by command: Chat owns the row; a 404 is authoritative; a cold/down peek
++ // answers null, never Live's own frozen table. ──
d2.run('DELETE FROM channel_sounds WHERE channel_owner_id = ?', [streamer]);
d2.run("INSERT INTO channel_sounds (channel_owner_id, command, url, created_by) VALUES (?, 'honk', '/sounds/h.mp3', ?)", [streamer, streamer]);
chatReads._reset();
++ assert.strictEqual(chatReads.soundByCommandPeek(streamer, 'honk'), null, 'a cold peek answers null, never Live\'s frozen table');
++ for (let i = 0; i < 300 && !chatReads.soundByCommandPeek(streamer, 'honk'); i++) await sleep(10);
const honk = chatReads.soundByCommandPeek(streamer, 'honk');
-- assert.ok(honk && honk.command === 'honk', 'Chat answers the sound by command');
++ assert.ok(honk && honk.command === 'honk', 'the warmed peek answers Chat\'s sound by command');
assert.strictEqual(chatReads.soundByCommandPeek(streamer, '!HONK').command, 'honk', 'the command is normalized');
assert.strictEqual(await chatReads.soundByCommand(streamer, 'nope'), null, 'Chat: no such sound');
d2.run("INSERT INTO channel_sounds (channel_owner_id, command, url, created_by) VALUES (?, 'nope', '/sounds/n.mp3', ?)", [streamer, streamer]);
assert.strictEqual(chatReads.soundByCommandPeek(streamer, 'nope'), null, 'a Chat 404 is cached: Live\'s own row is not used');
chatReads._reset();
readState.down = true;
-- assert.ok(chatReads.soundByCommandPeek(streamer, 'honk'), 'a cold + down peek falls back to Live\'s own table');
++ assert.strictEqual(chatReads.soundByCommandPeek(streamer, 'honk'), null, 'a cold + down peek answers null, never Live\'s frozen table');
readState.down = false;
chatReads._reset();
d2.run("INSERT INTO channel_sounds (channel_owner_id, command, url, created_by) VALUES (?, 'bare404', '/sounds/b.mp3', ?)", [streamer, streamer]);
const bare = await chatReads.soundByCommand(streamer, 'bare404');
-- assert.ok(bare && bare.command === 'bare404', 'a 404 without Chat\'s own body is not "no such sound": Live\'s table answers');
++ assert.strictEqual(bare, null, 'a 404 without Chat\'s own body is not "no such sound": null, never Live\'s table');
chatReads._reset();
}
++ // 11c. The mirror's retirement, checked where it would bite: a stale local row must not
++ // answer a chat-mode read, the relay welcome asks Chat's first-chat, an asset removal uses
++ // the Media id Chat sent, and a call invite fails closed when the block state is unknown.
++ {
++ const chatReads = require('../server/chat/chat-reads');
++ const chatClient = require('../server/chat/chat-client');
++ const d2 = require('../server/db/database');
++
++ // (1) ttsOverridePeek ignores Live's stale override: Chat says "none".
++ {
++ const realReadTts = chatClient.readTtsOverride;
++ chatClient.readTtsOverride = async () => ({ tts_override: null });
++ chatReads._reset();
++ d2.run("INSERT OR REPLACE INTO tts_voice_overrides (identity_key, voice, pitch, speed, gap, set_by) VALUES ('user:stale', 'en+m7', 99, 200, 0, ?)", [admin]);
++ try {
++ assert.strictEqual(chatReads.ttsOverridePeek('user:stale'), null, "Chat's 'no override' wins over Live's stale row");
++ assert.strictEqual(chatReads.ttsOverridePeek('user:stale'), null, 'and stays null from the cache');
++ } finally {
++ chatClient.readTtsOverride = realReadTts;
++ d2.run("DELETE FROM tts_voice_overrides WHERE identity_key = 'user:stale'");
++ chatReads._reset();
++ }
++ }
++
++ // (2) The relay welcome asks Chat's first-chat read before the message send; Live's own
++ // frozen stream_first_chats neither welcomes nor suppresses.
++ {
++ const relay = require('../server/integrations/chat-relay-service');
++ const realReadFirstChat = chatClient.readFirstChat;
++ const realFollowUps = relay._relayFollowUps;
++ const welcomes = (name) => ingressCalls.filter((c) => c.family === 'events' && c.body.frame && c.body.frame.type === 'system' && new RegExp(`Welcome ${name}\\b`).test(String(c.body.frame.message)));
++ const relayed = (name) => ingressCalls.filter((c) => c.family === 'messages' && c.body.username === `[Twitch] ${name}`);
++ relay._relayFollowUps = () => {}; // the welcome decision is what this checks
++ chatReads._reset();
++ try {
++ // Chat: not first, and Live's copy has no row either — nothing to welcome.
++ d2.run('DELETE FROM stream_first_chats WHERE chatter_key = ?', ['ext:[Twitch] carol']);
++ chatClient.readFirstChat = async () => ({ first: false });
++ await relay._broadcastMessage({ platform: 'twitch', streamId }, 'carol', 'hello from twitch', {});
++ await waitFor(() => relayed('carol').length, 'the relayed line');
++ await sleep(50);
++ assert.strictEqual(welcomes('carol').length, 0, "Chat's not-first answer suppresses the welcome");
++
++ // Chat: first, even though Live's frozen copy has a row (it used to suppress it).
++ d2.run('INSERT INTO stream_first_chats (chatter_key, channel_user_id) VALUES (?, ?)', ['ext:[Twitch] dave', streamer]);
++ chatClient.readFirstChat = async () => ({ first: true });
++ await relay._broadcastMessage({ platform: 'twitch', streamId }, 'dave', 'first line', {});
++ await waitFor(() => welcomes('dave').length, "dave's welcome");
++ // A second line within Chat's short "first" cache window is not welcomed twice.
++ await relay._broadcastMessage({ platform: 'twitch', streamId }, 'dave', 'second line', {});
++ await waitFor(() => relayed('dave').length >= 2, 'the second relayed line');
++ await sleep(80);
++ assert.strictEqual(welcomes('dave').length, 1, "Chat's first-chat answer welcomes once, deduped in-process");
++ } finally {
++ chatClient.readFirstChat = realReadFirstChat;
++ relay._relayFollowUps = realFollowUps;
++ d2.run("DELETE FROM stream_first_chats WHERE chatter_key IN ('ext:[Twitch] carol', 'ext:[Twitch] dave')");
++ chatReads._reset();
++ }
++ }
++
++ // (3) /asset-sync remove-sound deletes the Media asset id Chat sent, never the one on
++ // Live's frozen channel_sounds row.
++ {
++ const media = require('../server/media-client');
++ const realRequest = media.request;
++ const deleted = [];
++ media.request = async (method, p) => { if (method === 'DELETE') deleted.push(p); return { ok: true }; };
++ try {
++ d2.run("INSERT OR REPLACE INTO channel_sounds (id, channel_owner_id, command, url, created_by, media_asset_id) VALUES (777001, ?, 'given', '/sounds/given.mp3', ?, 424242)", [streamer, streamer]);
++ const out = await call('POST', '/internal/chat-effects/asset-sync', { token: WRITE, body: { op: 'remove-sound', asset_id: 555111 } });
++ assert.strictEqual(out.status, 200);
++ await sleep(20);
++ } finally {
++ media.request = realRequest;
++ d2.run('DELETE FROM channel_sounds WHERE id = 777001');
++ }
++ assert.deepStrictEqual(deleted, ['/assets/555111'], 'the Media asset id in the request is the one deleted');
++ }
++
++ // (4) The call invite asks Chat for the block state: blocked refuses, an unknown state
++ // (Chat down past the cache) refuses too, never rings through.
++ {
++ const callerToken = jwt.sign({ sub: String(streamer), username: 'streamer', role: 'streamer' }, keys.privateKey, { algorithm: 'RS256', issuer: ISS, expiresIn: 300 });
++ const callUser = (body) => call('POST', '/api/streams/voice-channels/call-user', { token: callerToken, body });
++ chatReads._reset();
++ readState.dmBlocked = true;
++ try {
++ const blocked = await callUser({ user_id: viewer });
++ assert.strictEqual(blocked.status, 403, 'a blocked pair cannot ring');
++ assert.match(blocked.body.error, /cannot call this user/i);
++
++ readState.dmBlocked = false;
++ chatReads._reset();
++ const allowed = await callUser({ user_id: viewer });
++ assert.strictEqual(allowed.status, 200, `an unblocked pair rings (${allowed.text})`);
++ assert.strictEqual(allowed.body.invited, true);
++
++ readState.down = true;
++ chatReads._reset();
++ const unknown = await callUser({ user_id: viewer });
++ assert.strictEqual(unknown.status, 503, 'an unverifiable block state refuses the ring');
++ assert.match(unknown.body.error, /try again shortly/i);
++ } finally {
++ readState.dmBlocked = false;
++ readState.down = false;
++ chatReads._reset();
++ }
++ }
++ }
++
// 12. Robot commands from chat pass the control panel's gate: control mode, anonymous switch, whitelist, cooldown.
{
const sent = [];
ddiff --git a/test/chat-ingress.test.js b/test/chat-ingress.test.js
iindex 70ac3f03..57642651 100644
---- a/test/chat-ingress.test.js
++++ b/test/chat-ingress.test.js
@@@ -45,11 +45,13 @@ const network = http.createServer((req, res) => {
});
});
--// Stub Chat: records every ingress call; `fail` answers chosen families with a status.
++// Stub Chat: records every ingress call; `fail` answers chosen families with a status. Its first-chat
++// and hidden-relay-user reads answer from Live's own tables (emulating Chat's copy).
const calls = [];
const bridgeCalls = [];
const fail = {};
let nextId = 5000;
++let firstChatReads = 0;
const chat = http.createServer((req, res) => {
let raw = '';
req.on('data', (c) => { raw += c; });
@@@ -60,6 +62,18 @@ const chat = http.createServer((req, res) => {
if (req.url === '/internal/live/calls') bridgeCalls.push(JSON.parse(raw));
return res.end(JSON.stringify({ ok: true, results: [] }));
}
++ if (String(req.url).startsWith('/internal/chat/first-chat')) {
++ firstChatReads++;
++ const q = new URL(req.url, 'http://x').searchParams;
++ const d3 = require('../server/db/database');
++ const row = d3.get('SELECT 1 AS present FROM stream_first_chats WHERE chatter_key = ? AND channel_user_id = ?', [String(q.get('identity')), Number(q.get('channel_id'))]);
++ return res.end(JSON.stringify({ ok: true, first: !row }));
++ }
++ if (String(req.url).startsWith('/internal/chat/moderation/relay-users')) {
++ const q = new URL(req.url, 'http://x').searchParams;
++ const rows = require('../server/db/database').all('SELECT * FROM hidden_relay_users WHERE channel_id = ? OR channel_id IS NULL LIMIT ?', [Number(q.get('channel_id')), Number(q.get('limit') || 100)]);
++ return res.end(JSON.stringify({ ok: true, relay_users: rows }));
++ }
const m = req.url.match(/^\/internal\/chat\/(messages|events|moderation|invalidate|presence)(?:\?|$)/);
if (!m) { res.statusCode = 404; return res.end('{}'); }
const family = m[1];
@@@ -151,17 +165,27 @@ function assertSigned(c, what) {
assert.deepStrictEqual([rl.body.username, rl.body.frame.role, rl.body.mirror, rl.body.tts.identity_key], ['[Twitch] alice', 'external', true, 'twitch:[Twitch] alice']);
assertSigned(await waitFor(() => find('moderation', (b) => b.action === 'relay-record' && b.username === 'alice'), 'relay record'), 'relay record');
assertSigned(await waitFor(() => find('events', (b) => b.frame.type === 'system' && /Welcome alice/.test(b.frame.message)), 'welcome'), 'welcome');
-- assert.ok(d.prepare("SELECT 1 FROM stream_first_chats WHERE chatter_key = '[Twitch] alice' OR chatter_key = 'ext:[Twitch] alice'").get(), 'Live keeps its first-chat copy');
++ assert.ok(firstChatReads >= 1, "the welcome decision asked Chat's first-chat read");
++ assert.ok(!d.prepare("SELECT 1 FROM stream_first_chats WHERE chatter_key = '[Twitch] alice' OR chatter_key = 'ext:[Twitch] alice'").get(), 'Live writes no first-chat copy of its own in chat mode');
++ const welcomeCount = () => calls.filter((c) => c.family === 'events' && c.body.frame && /Welcome alice/.test(String(c.body.frame.message || ''))).length;
++ relay._broadcastMessage({ platform: 'twitch', streamId }, 'alice', 'second line', {});
++ await waitFor(() => find('messages', (b) => b.source_platform === 'twitch' && b.message === 'second line'), 'the second relay line');
++ await sleep(80);
++ assert.strictEqual(welcomeCount(), 1, 'a second line within the first-chat cache window is not welcomed twice');
// 3b. A hidden relay user is dropped before any call to Chat; unhiding resumes relaying.
const relayLines = (message) => calls.filter((c) => c.family === 'messages' && c.body.username === '[Twitch] alice' && (!message || c.body.message === message)).length;
++ const chatReads = require('../server/chat/chat-reads');
d.prepare("INSERT INTO hidden_relay_users (channel_id, platform, external_username, action, created_by) VALUES (?, 'twitch', 'alice', 'hide', ?)").run(channel.id, admin);
++ chatReads.invalidate('hru:'); // the mod route's hide drops the cached list; the test writes the row directly
++ await waitFor(() => chatReads.isRelayUserHidden(channel.id, 'twitch', 'alice'), "Chat's hidden list to warm");
const beforeHidden = relayLines();
relay._broadcastMessage({ platform: 'twitch', streamId }, 'alice', 'should not relay', {});
await sleep(80);
assert.strictEqual(relayLines(), beforeHidden, 'a hidden relay user is dropped before Chat');
assert.strictEqual(relayLines('should not relay'), 0);
d.prepare("DELETE FROM hidden_relay_users WHERE platform = 'twitch' AND external_username = 'alice'").run();
++ chatReads.invalidate('hru:'); // as the unhide route does
relay._broadcastMessage({ platform: 'twitch', streamId }, 'alice', 'relayed again', {});
assertSigned(await waitFor(() => find('messages', (b) => b.source_platform === 'twitch' && b.message === 'relayed again'), 'the unhidden relay line'), 'relay line after unhide');
@@@ -194,7 +218,7 @@ function assertSigned(c, what) {
const del = find('moderation', (b) => b.action === 'delete-message');
assertSigned(del, 'message delete');
assert.deepStrictEqual([del.body.id, del.body.deleted_by, del.body.key], [900010, admin, 'live:moderation:delete:900010']);
-- assert.strictEqual(db.getChatMessageById(900010).is_deleted, 1, "Live's copy is deleted too (the read mirror is on in production)");
++ assert.strictEqual(db.getChatMessageById(900010).is_deleted, 0, "Live's own copy is untouched: Chat owns the delete (the read mirror is retired)");
assertSigned(await waitFor(() => find('moderation', (b) => b.action === 'log' && b.action_type === 'message_delete'), 'delete log'), 'delete log');
const ban = await mod(`/users/${viewer}/ban`, { reason: 'spam' });
assert.ok(ban.status < 300, `ban answered ${ban.status}`);
ddiff --git a/test/chat-mirror-removed.test.js b/test/chat-mirror-removed.test.js
nnew file mode 100644
iindex 00000000..3229a611
---- /dev/null
++++ b/test/chat-mirror-removed.test.js
@@@ -0,0 +1,39 @@
++'use strict';
++
++// T3: the Live end of the retired chat read mirror is gone. OpenVibe.Chat #25 (deployed) removed the
++// sender (its LIVE_MIRROR writer and the capture triggers); this change removed Live's receiver —
++// POST /internal/chat-effects/mirror and the chat-delivery.js mirror() helper (MIRROR_WRITES) — and
++// its call sites. Static guard: nothing under server/ names the receiver, its capability, the mirror
++// tables or the helper. Live's own chat tables stay for now (a later change drops them).
++
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++
++const ROOT = path.join(__dirname, '..');
++
++const PATTERNS = [
++ ['chat-effects/mirror', /chat-effects\/mirror/],
++ ['live.chat_mirror.write', /live\.chat_mirror\.write/],
++ ['MIRROR_TABLES', /\bMIRROR_TABLES\b/],
++ ['applyMirror', /\bapplyMirror\b/],
++ ['liveColumns', /\bliveColumns\b/],
++ ['MIRROR_WRITES', /\bMIRROR_WRITES\b/],
++ ['delivery.mirror', /delivery\.mirror\b/],
++];
++
++const offenders = [];
++(function walk(dir) {
++ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
++ const p = path.join(dir, e.name);
++ if (e.isDirectory()) { walk(p); continue; }
++ if (!p.endsWith('.js')) continue;
++ const rel = path.relative(ROOT, p);
++ const src = fs.readFileSync(p, 'utf8');
++ for (const [name, re] of PATTERNS) if (re.test(src)) offenders.push(`${rel}: ${name}`);
++ }
++})(path.join(ROOT, 'server'));
++
++assert.deepStrictEqual(offenders, [], `server/ still references the retired chat read mirror:\n${offenders.join('\n')}`);
++
++console.log('chat mirror removed: nothing under server/ references chat-effects/mirror, live.chat_mirror.write, MIRROR_TABLES/applyMirror/liveColumns/MIRROR_WRITES or delivery.mirror — all checks passed');