openvibers/openvibe.live Commits

CALLS_AUTHORITY flag (WS-I task 1): with CALLS_AUTHORITY=chat the go-live, stream-end, WHIP teardown and admin force-end hooks create and remove stream voice channels in OpenVibe.Chat (POST/DELETE /internal/calls/stream-channel with the chat bridge's service token, retried on 5xx, superseded retries dropped) instead of Live's call server, and Live pushes Chat's call-user notification (notify/call-invite); the default live changes nothing and Live's /ws/call and voice-channel routes stay for one release

OP OpenVibers committed 25 Sept 2026 · 5abf2b133b · 10 files, +302 −6


Changes

ddiff --git a/.env.example b/.env.example
iindex 1f89c70b..bd52f154 100644
---- a/.env.example
++++ b/.env.example
@@@ -158,6 +158,12 @@ OV_COMMUNITY_INTERNAL_URL=http://127.0.0.1:4200
# chat calls to Chat and keeps its chat tables as Chat's read mirror. Unset = Live runs chat.
#CHAT_AUTHORITY=chat
OV_CHAT_INTERNAL_URL=http://127.0.0.1:4400
++# Voice/video calls run in OpenVibe.Chat when this is "chat" (WS-I task 1;
++# server/streaming/calls-authority.js): go-live, stream end, WHIP teardown and the admin force-end
++# create/remove stream voice channels in Chat (POST/DELETE /internal/calls/stream-channel, the chat
++# bridge's service token) instead of Live's call server. Set it together with the nginx include that
++# sends /ws/call and the call routes to Chat (OpenVibe.Chat docs/calls-cutover.md). Unset = Live.
++#CALLS_AUTHORITY=chat
# Money authority (roadmap Wave 8, ADR-012). live (default): Live's own Vibes columns, exactly as
# before. billing: OpenVibe.Billing is the ledger — Live calls it for checkouts, donations, cashouts,
ddiff --git a/docs/architecture.md b/docs/architecture.md
iindex 61b1db45..323c88a7 100644
---- a/docs/architecture.md
++++ b/docs/architecture.md
@@@ -88,7 +88,11 @@ public/js/app-*.js route code split out of app.js: home, channel, media, c
- **Voice channels.** `public/js/call.js` + `voice-channels.js` (feature `voice`, chat route): full-mesh
WebRTC, signalling over `/ws/call` (`server/streaming/call-server.js`). The newcomer offers, existing
members answer; the server pushes the channel list to every chat socket (`voice-channels` message);
-- TURN credentials come from `server/net/turn.js` (short-lived with `TURN_AUTH_SECRET`).
++ TURN credentials come from `server/net/turn.js` (short-lived with `TURN_AUTH_SECRET`). OpenVibe.Chat
++ runs the same protocol and routes (its `server/calls/`); `CALLS_AUTHORITY=chat`
++ (`server/streaming/calls-authority.js`) plus an nginx include moves calls there, and then go-live,
++ stream end, WHIP teardown and the admin force-end create/remove stream voice channels in Chat
++ (OpenVibe.Chat `docs/calls-cutover.md`).
- **Budgets.** `scripts/perf/check-budgets.js` (in `npm test`) fails if the home page's HTML, eager
JavaScript or blocking CSS grow past recorded limits, or if route code returns to the home page.
ddiff --git a/server/admin/routes.js b/server/admin/routes.js
iindex f2be55d9..e94678c5 100644
---- a/server/admin/routes.js
++++ b/server/admin/routes.js
@@@ -344,7 +344,7 @@ router.delete('/streams/:id', (req, res) => {
try { require('../integrations/ai-chatbot-service').stopForStream(stream.id); } catch { /* non-critical */ }
try { require('../streaming/broadcast-server').endStream(stream.id); } catch { /* no room */ }
try { require('../streaming/webrtc-sfu').closeRoom(`stream-${stream.id}`); } catch { /* no room */ }
-- try { require('../streaming/call-server').removeStreamChannel(stream.id); } catch { /* no channel */ }
++ try { require('../streaming/calls-authority').removeStreamChannel(stream.id); } catch { /* no channel */ }
// Accountability: force-ending someone's live stream is a moderation action
// and MUST be logged (previously it left no trace at all).
ddiff --git a/server/chat/live-context-routes.js b/server/chat/live-context-routes.js
iindex fef87de7..a9b402fd 100644
---- a/server/chat/live-context-routes.js
++++ b/server/chat/live-context-routes.js
@@@ -24,7 +24,7 @@
* /internal/chat-effects/* capability live.chat_effects.write — side effects (CHAT_AUTHORITY=chat only)
* 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, asset-sync
++ * 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
@@@ -553,6 +553,36 @@ effectsRouter.post('/notify/dm', (req, res) => {
res.json({ ok: true });
});
++// A call-user ring from OpenVibe.Chat's call server (CALLS_AUTHORITY=chat): the cross-site
++// notification Live's POST /api/streams/voice-channels/call-user pushed itself.
++effectsRouter.post('/notify/call-invite', (req, res) => {
++ const caller = db.getUserById(int(req.body?.caller_id));
++ const targetId = int(req.body?.target_id);
++ const channelId = String(req.body?.channel_id || '').slice(0, 100);
++ const channelName = String(req.body?.channel_name || 'Voice Channel').slice(0, 100);
++ if (!caller || !targetId || !db.getUserById(targetId) || !channelId) return fail(res, 400, 'caller_id, target_id and channel_id required');
++ try {
++ const { pushNotification, actorInfo } = require('../utils/notify');
++ const callerName = caller.display_name || caller.username || 'Someone';
++ pushNotification({
++ user_id: targetId,
++ type: 'VC_CALL_INVITE',
++ title: `${callerName} is calling you`,
++ message: `Join voice channel: ${channelName}`,
++ url: `${config.baseUrl}/?vcInvite=${encodeURIComponent(channelId)}`,
++ rich_content: {
++ context: {
++ channel_id: channelId,
++ channel_name: channelName,
++ caller_username: caller.username,
++ },
++ },
++ ...actorInfo(caller, callerName),
++ });
++ } catch { /* non-critical */ }
++ res.json({ ok: true });
++});
++
effectsRouter.post('/notify/dm-read', (req, res) => {
try {
const { markNotificationsRead } = require('../utils/notify');
ddiff --git a/server/index.js b/server/index.js
iindex e5988ade..71e9a92f 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -1184,6 +1184,9 @@ async function start() {
// 4d. Initialize group call signaling server
callServer.init(server);
++ if (require('./streaming/calls-authority').isChat()) {
++ console.log('[Calls] CALLS_AUTHORITY=chat — stream voice channels are created and removed in OpenVibe.Chat; Live\'s /ws/call stays up, unreached once nginx routes it to Chat');
++ }
for (const stream of db.getLiveStreams()) {
robotStreamerService.startForStream(stream).catch((err) => {
ddiff --git a/server/streaming/calls-authority.js b/server/streaming/calls-authority.js
nnew file mode 100644
iindex 00000000..029e00f4
---- /dev/null
++++ b/server/streaming/calls-authority.js
@@@ -0,0 +1,97 @@
++'use strict';
++/**
++ * Who runs voice/video calls (roadmap WS-I task 1). CALLS_AUTHORITY is read here and nowhere else.
++ *
++ * live (default, anything but "chat"): Live's own call server (./call-server.js) serves /ws/call and
++ * the voice-channel routes, and the stream lifecycle creates and removes stream voice channels in
++ * it — exactly as before.
++ * chat: OpenVibe.Chat owns calls (nginx sends /ws/call, /api/streams/voice-channels… and
++ * /api/streams/:id/call there; Chat runs with CHAT_CALLS=1 — OpenVibe.Chat docs/calls-cutover.md).
++ * Go-live with a call mode, stream end, the WHIP teardown and the admin force-end ask Chat instead
++ * of the local call server:
++ * POST /internal/calls/stream-channel { stream_id, mode, user_id }
++ * DELETE /internal/calls/stream-channel/:streamId
++ * with Live's service token for audience openvibe.chat — the same principal and grant
++ * (chat.live_bridge.write) Live's chat bridge uses (../chat/chat-remote.js). Fire-and-forget for the
++ * caller: a network error or a 5xx is retried after 1 s, 5 s and 15 s, unless a later call for the
++ * same stream 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. Live's /ws/call and voice-channel
++ * routes stay mounted for one release, unreached once nginx routes those paths to Chat.
++ */
++const { CHAT_URL } = require('../chat/chat-authority');
++
++const AUDIENCE = 'openvibe.chat';
++const RETRY_MS = [1000, 5000, 15000];
++const TIMEOUT_MS = 5000;
++
++function authority() {
++ return String(process.env.CALLS_AUTHORITY || '').trim().toLowerCase() === 'chat' ? 'chat' : 'live';
++}
++function isChat() { return authority() === 'chat'; }
++
++const local = () => require('./call-server');
++
++async function send(method, path, body, retried = false) {
++ const principal = require('../net/network-principal');
++ const res = await fetch(`${CHAT_URL}${path}`, {
++ method,
++ headers: { Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}), ...(await principal.serviceHeaders(AUDIENCE)) },
++ body: body ? JSON.stringify(body) : undefined,
++ signal: AbortSignal.timeout(TIMEOUT_MS),
++ });
++ if (res.status === 401 && !retried) { principal.invalidate(AUDIENCE); return send(method, path, body, true); }
++ const data = await res.json().catch(() => null);
++ if (!res.ok) {
++ const err = new Error(`Chat ${res.status}${data && data.error ? `: ${data.error}` : ''}`);
++ err.status = res.status;
++ throw err;
++ }
++ return data;
++}
++
++const latest = new Map(); // streamId → seq of the newest call for it
++let seq = 0;
++const stats = { sent: 0, retried: 0, superseded: 0, failed: 0, lastError: null };
++
++/** Send one hook to Chat, retrying as above. Resolves (never rejects) with Chat's answer or null. */
++function deliver(streamId, method, path, body) {
++ const mine = ++seq;
++ latest.set(streamId, mine);
++ const attempt = async (n) => {
++ if (latest.get(streamId) !== mine) { stats.superseded++; return null; }
++ try {
++ const out = await send(method, path, body);
++ stats.sent++;
++ if (latest.get(streamId) === mine) latest.delete(streamId);
++ return out;
++ } catch (err) {
++ stats.lastError = err.message;
++ const retry = !(err.status >= 400 && err.status < 500) && n < RETRY_MS.length;
++ if (!retry) {
++ stats.failed++;
++ console.warn(`[Calls] Chat refused ${method} ${path}: ${err.message}`);
++ if (latest.get(streamId) === mine) latest.delete(streamId);
++ return null;
++ }
++ stats.retried++;
++ console.warn(`[Calls] ${method} ${path} failed (${err.message}); retrying in ${RETRY_MS[n] / 1000}s`);
++ await new Promise((r) => { const t = setTimeout(r, RETRY_MS[n]); if (t.unref) t.unref(); });
++ return attempt(n + 1);
++ }
++ };
++ return attempt(0);
++}
++
++/** Go-live with a call mode, or the mode changed: the stream's voice channel. */
++function createStreamChannel(streamId, mode, userId) {
++ if (!isChat()) return local().createStreamChannel(streamId, mode, userId);
++ return deliver(Number(streamId), 'POST', '/internal/calls/stream-channel', { stream_id: Number(streamId), mode, user_id: Number(userId) });
++}
++
++/** The stream ended (or was force-ended): its call ends and its voice channel goes. */
++function removeStreamChannel(streamId) {
++ if (!isChat()) return local().removeStreamChannel(streamId);
++ return deliver(Number(streamId), 'DELETE', `/internal/calls/stream-channel/${Number(streamId)}`);
++}
++
++module.exports = { authority, isChat, createStreamChannel, removeStreamChannel, stats, RETRY_MS, AUDIENCE };
ddiff --git a/server/streaming/routes.js b/server/streaming/routes.js
iindex abaf0eba..bcb63db8 100644
---- a/server/streaming/routes.js
++++ b/server/streaming/routes.js
@@@ -35,6 +35,8 @@ const i18n = require('../i18n/translate');
const robotStreamerService = require('../integrations/robotstreamer-service');
const chatRelayService = require('../integrations/chat-relay-service');
const chatServer = require('../chat/chat-server');
++// Stream voice channels: Live's call server, or OpenVibe.Chat's with CALLS_AUTHORITY=chat.
++const callsAuthority = require('./calls-authority');
const { sanitizeOfflineHtml, sanitizeOfflineCss } = require('./offline-html-sanitize');
const multer = require('multer');
const path = require('path');
@@@ -2044,7 +2046,7 @@ router.post('/', requireAuth, (req, res) => {
// Set call mode if provided — create a stream voice channel
if (callMode) {
db.run('UPDATE streams SET call_mode = ? WHERE id = ?', [callMode, streamId]);
-- callServer.createStreamChannel(streamId, callMode, req.user.id);
++ callsAuthority.createStreamChannel(streamId, callMode, req.user.id);
}
let endpoint = {};
@@@ -2179,7 +2181,7 @@ router.delete('/:id', requireAuth, (req, res) => {
}
// End any active group call / remove stream voice channel
-- callServer.removeStreamChannel(stream.id);
++ callsAuthority.removeStreamChannel(stream.id);
robotStreamerService.stopForStream(stream.id);
chatRelayService.stopForStream(stream.id);
ddiff --git a/server/streaming/whip-handler.js b/server/streaming/whip-handler.js
iindex e924b586..e9b83a5d 100644
---- a/server/streaming/whip-handler.js
++++ b/server/streaming/whip-handler.js
@@@ -203,7 +203,7 @@ function endActiveWhipStream(streamId, reason = 'whip_cleanup') {
try { require('../integrations/ai-chatbot-service').stopForStream(streamId); } catch (err) { /* ignore */ }
try { require('./broadcast-server').endStream(streamId); } catch (err) { /* ignore */ }
try { webrtcSFU.closeRoom(`stream-${streamId}`); } catch (err) { /* ignore */ }
-- try { require('./call-server').removeStreamChannel(streamId); } catch (err) { /* ignore */ }
++ try { require('./calls-authority').removeStreamChannel(streamId); } catch (err) { /* ignore */ }
}
// ── SDP Parsing Utilities ────────────────────────────────────
ddiff --git a/test/calls-authority.test.js b/test/calls-authority.test.js
nnew file mode 100644
iindex 00000000..bbbacd45
---- /dev/null
++++ b/test/calls-authority.test.js
@@@ -0,0 +1,139 @@
++'use strict';
++
++// Who runs calls (WS-I task 1, server/streaming/calls-authority.js). CALLS_AUTHORITY unset (or
++// anything but "chat"): stream voice channels are made and removed by Live's own call server and
++// nothing is sent anywhere — as before. "chat": the same hooks become POST/DELETE
++// /internal/calls/stream-channel on OpenVibe.Chat with Live's service token for audience
++// openvibe.chat, retried after a network error or a 5xx, never after a 4xx, and a retry that a later
++// call for the same stream superseded is dropped. The go-live, stream-end, WHIP and admin force-end
++// hooks all go through it. Against a stub Network + Chat.
++
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++
++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-calls-authority-'));
++process.env.DB_PATH = path.join(tmp, 'live.db');
++process.env.OV_OAUTH_CLIENT_ID = 'live';
++process.env.OV_OAUTH_CLIENT_SECRET = 'live-secret';
++delete process.env.CALLS_AUTHORITY;
++const quiet = console.log;
++console.log = () => {};
++console.warn = () => {};
++
++const seen = []; // requests to Chat's /internal/calls
++const tokenAudiences = [];
++let answer = () => [200, { ok: true }];
++const stub = http.createServer((req, res) => {
++ let raw = '';
++ req.on('data', (c) => { raw += c; });
++ req.on('end', () => {
++ res.setHeader('Content-Type', 'application/json');
++ if (req.url === '/oauth/token') {
++ const aud = new URLSearchParams(raw).get('audience');
++ tokenAudiences.push(aud);
++ return res.end(JSON.stringify({ access_token: `svc-live-for-${aud}`, token_type: 'Bearer', expires_in: 300 }));
++ }
++ if (req.url.startsWith('/internal/calls/')) {
++ const r = { method: req.method, url: req.url, auth: req.headers.authorization || null, body: raw ? JSON.parse(raw) : null };
++ seen.push(r);
++ const [status, body] = answer(r);
++ res.statusCode = status;
++ return res.end(JSON.stringify(body));
++ }
++ res.statusCode = 404; res.end('{}');
++ });
++});
++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));
++
++(async () => {
++ const port = await listen(stub);
++ process.env.OV_NETWORK_INTERNAL_URL = `http://127.0.0.1:${port}`;
++ process.env.OV_CHAT_INTERNAL_URL = `http://127.0.0.1:${port}`;
++
++ const db = require('../server/db/database');
++ db.getStreamById = (id) => ({ id: Number(id), user_id: 7, title: `Stream ${id} title`, is_live: 1 });
++ const calls = require('../server/streaming/calls-authority');
++ const callServer = require('../server/streaming/call-server');
++ calls.RETRY_MS.splice(0, calls.RETRY_MS.length, 30, 30, 30);
++
++ let exit = 0;
++ try {
++ // 1. The flag.
++ for (const v of [undefined, '', 'live', 'LIVE', 'other']) {
++ if (v === undefined) delete process.env.CALLS_AUTHORITY; else process.env.CALLS_AUTHORITY = v;
++ assert.strictEqual(calls.authority(), 'live', String(v));
++ }
++ for (const v of ['chat', 'CHAT', ' chat ']) { process.env.CALLS_AUTHORITY = v; assert.strictEqual(calls.authority(), 'chat', v); }
++ delete process.env.CALLS_AUTHORITY;
++
++ // 2. Default: Live's call server, nothing sent.
++ const ch = calls.createStreamChannel(41, 'mic', 7);
++ assert.deepStrictEqual([ch.id, ch.mode, ch.name, ch.createdBy], ['stream-41', 'mic', 'Stream 41 title', 7]);
++ assert.ok(callServer.channels.has('stream-41'));
++ calls.removeStreamChannel(41);
++ assert.ok(!callServer.channels.has('stream-41'));
++ await sleep(50);
++ assert.deepStrictEqual(seen, [], 'CALLS_AUTHORITY unset sends nothing to Chat');
++
++ // 3. chat: the hooks go to Chat's internal endpoints with Live's service token; nothing local.
++ process.env.CALLS_AUTHORITY = 'chat';
++ const created = await calls.createStreamChannel(42, 'mic+cam', 7);
++ assert.deepStrictEqual(created, { ok: true });
++ assert.deepStrictEqual(seen[0], { method: 'POST', url: '/internal/calls/stream-channel', auth: 'Bearer svc-live-for-openvibe.chat', body: { stream_id: 42, mode: 'mic+cam', user_id: 7 } });
++ assert.ok(tokenAudiences.includes('openvibe.chat'));
++ assert.ok(!callServer.channels.has('stream-42'), 'Live\'s call server is not used');
++ await calls.removeStreamChannel(42);
++ assert.deepStrictEqual([seen[1].method, seen[1].url, seen[1].body], ['DELETE', '/internal/calls/stream-channel/42', null]);
++
++ // 4. A 5xx (or Chat down) is retried; a 4xx is not.
++ seen.length = 0;
++ let n = 0;
++ answer = () => (++n === 1 ? [503, { error: 'restarting' }] : [200, { ok: true }]);
++ assert.deepStrictEqual(await calls.createStreamChannel(43, 'mic', 7), { ok: true });
++ assert.strictEqual(seen.length, 2, 'retried once');
++ seen.length = 0;
++ answer = () => [409, { error: 'Calls are not served by Chat (CHAT_CALLS is not set)', code: 'calls.off' }];
++ assert.strictEqual(await calls.createStreamChannel(43, 'mic', 7), null);
++ assert.strictEqual(seen.length, 1, 'a refusal is not retried');
++ assert.ok(/409/.test(calls.stats.lastError));
++
++ // 5. A retry that a later call for the same stream superseded is dropped: a stream that ended
++ // while its go-live was being retried does not get its channel back.
++ seen.length = 0;
++ answer = (r) => (r.method === 'POST' ? [502, { error: 'bad gateway' }] : [200, { ok: true, removed: false }]);
++ const pending = calls.createStreamChannel(44, 'mic', 7);
++ await sleep(10);
++ await calls.removeStreamChannel(44);
++ assert.strictEqual(await pending, null);
++ await sleep(120);
++ assert.deepStrictEqual(seen.map((r) => r.method), ['POST', 'DELETE'], 'the go-live was not retried after the stream ended');
++ assert.ok(calls.stats.superseded >= 1);
++
++ // 6. Every stream hook goes through the authority, not straight to Live's call server.
++ const src = (f) => fs.readFileSync(path.join(__dirname, '..', 'server', f), 'utf8');
++ const routes = src('streaming/routes.js');
++ assert.ok(routes.includes('callsAuthority.createStreamChannel(streamId, callMode, req.user.id)'), 'go-live');
++ assert.ok(routes.includes('callsAuthority.removeStreamChannel(stream.id)'), 'stream end');
++ for (const f of ['streaming/whip-handler.js', 'admin/routes.js']) {
++ assert.ok(/calls-authority'\)\.removeStreamChannel\(/.test(src(f)), f);
++ assert.ok(!/call-server'\)\.removeStreamChannel\(/.test(src(f)), `${f} no longer calls Live's call server directly`);
++ }
++ assert.strictEqual((src('streaming/calls-authority.js').match(/process\.env\.CALLS_AUTHORITY/g) || []).length, 1);
++ for (const f of ['index.js', 'streaming/routes.js', 'streaming/whip-handler.js', 'admin/routes.js']) {
++ assert.ok(!src(f).includes('process.env.CALLS_AUTHORITY'), `${f} does not read the flag itself`);
++ }
++ } catch (err) {
++ exit = 1;
++ console.error(err);
++ }
++ stub.close();
++ try { callServer.close(); } catch { /* */ }
++ fs.rmSync(tmp, { recursive: true, force: true });
++ console.log = quiet;
++ console.log(exit ? 'calls authority: FAILED' : 'calls authority: all checks passed');
++ process.exit(exit);
++})();
ddiff --git a/test/chat-context.test.js b/test/chat-context.test.js
iindex bad54822..c4ec7a21 100644
---- a/test/chat-context.test.js
++++ b/test/chat-context.test.js
@@@ -207,6 +207,21 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
assert.strictEqual((await call('POST', '/internal/chat-effects/alert-sound', { token: WRITE, body: { channel_id: channel.id, actor_user_id: streamer, kind: 'donation', url: '/etc/passwd' } })).status, 400, 'files stay in the sounds dir');
assert.strictEqual((await call('POST', '/internal/chat-effects/alert-sound', { token: WRITE, body: { channel_id: channel.id, actor_user_id: streamer, kind: 'donation', url: snd } })).status, 200);
++ // 8b. A ring from Chat's call server (CALLS_AUTHORITY=chat): Live pushes the VC_CALL_INVITE it used to.
++ const notify = require('../server/utils/notify');
++ const pushed = [];
++ const origPush = notify.pushNotification;
++ notify.pushNotification = (p) => pushed.push(p);
++ try {
++ assert.strictEqual((await call('POST', '/internal/chat-effects/notify/call-invite', { token: READ, body: { caller_id: streamer, target_id: viewer, channel_id: 'user-1-x' } })).status, 403, 'an effect');
++ assert.strictEqual((await call('POST', '/internal/chat-effects/notify/call-invite', { token: WRITE, body: { caller_id: streamer, target_id: 999999, channel_id: 'user-1-x' } })).status, 400);
++ assert.strictEqual((await call('POST', '/internal/chat-effects/notify/call-invite', { token: WRITE, body: { caller_id: streamer, target_id: viewer, channel_id: `user-${streamer}-x`, channel_name: 'STREAMER\'s call' } })).status, 200);
++ } finally { notify.pushNotification = origPush; }
++ assert.strictEqual(pushed.length, 1);
++ assert.deepStrictEqual([pushed[0].user_id, pushed[0].type, pushed[0].title, pushed[0].message, pushed[0].sender_id, pushed[0].rich_content.context.caller_username],
++ [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.
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');