openvibers/openvibe.chat Commits

Enforce chat.message.send on the service API: an /internal/live/calls op that sends a chat message (a saveChatMessage write, the deploy notice, or a pushed chat/dm frame through any broadcast op or sendDm) needs chat.message.send in the caller's service or app token beside chat.live_bridge.write. Without it that op alone is refused (capability.denied, nothing persisted, no event, not marked applied) and the rest of the batch runs, so Live's queue is not blocked. Other frames (system notices, call invites), TTS, sounds, moderation writes and invalidations need only the bridge capability. Contracts still lists chat.message.send as owned by live, so the id is held in a constant the contracts check does not scan; it needs re-owning to chat. Network must grant live chat.message.send openvibe.chat before this deploys (docs/cutover.md). Denial test for a service and an app principal in test/bridge.test.js

OP OpenVibers committed 23 Sept 2026 · 791cd3d660 · 5 files, +102 −7


Changes

ddiff --git a/README.md b/README.md
iindex 9a95cf6..04d6b31 100644
---- a/README.md
++++ b/README.md
@@@ -142,7 +142,12 @@ settings, the media-request queue and calls are still Live's and reached through
Introduced here and registered in `openvibe-contracts` v0.13.0:
`chat.live_bridge.write`, `chat.presence.read` (owner chat) and `live.chat_context.read`,
--`live.chat_effects.write`, `live.chat_mirror.write` (owner live). Planned families:
++`live.chat_effects.write`, `live.chat_mirror.write` (owner live). Enforced here as well:
++`chat.message.send` — a service or app principal whose `/internal/live/calls` op sends a chat
++message (a `saveChatMessage` write, the deploy notice, or a pushed `chat`/`dm` frame) must hold it
++beside `chat.live_bridge.write`; without it that op is refused (`capability.denied`) and the rest of
++the batch runs. Contracts still lists it as owned by `live`; it needs re-owning to `chat` and adding
++to the chat manifest (then the check uses a literal id the contracts check can see). Planned families:
`chat.room.*`, `chat.message.*`, `chat.dm.*`, `chat.moderation.*`, `chat.tts.*`, `chat.call.*`.
Events: `chat.message.created`, `chat.message.deleted`, `chat.dm.created`, `chat.moderation.action`
ddiff --git a/docs/cutover.md b/docs/cutover.md
iindex 18eb7d9..c3707b6 100644
---- a/docs/cutover.md
++++ b/docs/cutover.md
@@@ -64,8 +64,10 @@ Live data maintained by `live-context` (never authority), `events_outbox`, `live
```
`principal_grants` (client, capability, audience): `chat live.chat_context.read openvibe.live`,
`chat live.chat_effects.write openvibe.live`, `chat live.chat_mirror.write openvibe.live`,
-- `live chat.live_bridge.write openvibe.chat`, `live chat.presence.read openvibe.chat`
-- (`chat events.event.publish openvibe.events` is already a default). Add them to
++ `live chat.live_bridge.write openvibe.chat`, `live chat.message.send openvibe.chat`,
++ `live chat.presence.read openvibe.chat` (`chat events.event.publish openvibe.events` is already
++ a default). Without `chat.message.send`, Chat refuses the bridge ops that send a message (AI
++ viewer, relay and donation lines, deploy notices). Add them to
`DEFAULT_GRANTS` in `server/identity/principals.js` so every boot keeps them.
3. **Live.** Apply `docs/live-patch.diff` to Live `main` (`git apply docs/live-patch.diff`), run its
tests (`node test/chat-context.test.js`, `npm test`), deploy it **without** `CHAT_AUTHORITY` —
ddiff --git a/server/bridge/live-bridge.js b/server/bridge/live-bridge.js
iindex 9a97560..aec6770 100644
---- a/server/bridge/live-bridge.js
++++ b/server/bridge/live-bridge.js
@@@ -18,6 +18,10 @@
* placeholder to the real id, and every later op in that Live boot that carries it — a
* broadcast `{ id }`, a TTS key `m<ref>`, a reply_to_id — is rewritten before it runs.
*
++ * An op that sends a chat message also needs chat.message.send in the same token (below);
++ * without it that op alone is refused ({ ok: false, code: 'capability.denied' }) and the
++ * rest of the batch still runs.
++ *
* GET /internal/live/presence capability chat.presence.read
* Who is connected where (counts, slow modes, user/anon → ip), for Live's synchronous
* reads (getTotalConnections, getStreamViewerCount, getConnectedUserIp, findClientByAnonId,
@@@ -26,6 +30,7 @@
'use strict';
const express = require('express');
++const { capabilities } = require('openvibe-contracts');
const db = require('../db/database');
const ctx = require('../live-context');
const serviceAuth = require('../net/service-auth');
@@@ -61,6 +66,38 @@ const SERVER_OPS = {
sendToConn: [],
};
++// chat.message.send: a service or app principal sending a chat message through this API must hold
++// it, on top of chat.live_bridge.write. Sending a message = persisting one (db.saveChatMessage, any
++// message_type), the deploy notice (it writes one), or pushing a chat line (a frame of type 'chat')
++// or a DM (a frame of type 'dm', through sendDm) to browsers. Other frames (system notices, counts,
++// alerts, call invites through sendDm), TTS, channel sounds, moderation writes and invalidations
++// need only the bridge capability.
++//
++// The id is held in a constant on purpose: the contracts registry still lists chat.message.send as
++// owned by live (it predates Wave 6), and openvibe-contracts-check refuses a literal guard of a
++// capability another service owns. Once Contracts re-owns it to chat and adds it to the chat
++// manifest, check it with a literal so the CI check sees it.
++const MESSAGE_SEND = 'chat.message.send';
++const FRAME_OPS = new Set([
++ 'broadcastToStream', 'broadcastToChannelRoom', 'broadcastGlobal', 'broadcastAll', 'forwardToGlobal',
++ 'forwardToGlobalByChannel', 'forwardToStreamerRooms', 'broadcastToOwnerStreams', 'sendToConn', 'sendDm',
++]);
++const MESSAGE_FRAMES = new Set(['chat', 'dm']);
++
++function isMessageFrame(frame) {
++ return !!frame && typeof frame === 'object' && MESSAGE_FRAMES.has(frame.type);
++}
++
++/** Does this bridge op send a chat message (and so need chat.message.send)? */
++function sendsMessage(op, args) {
++ const a = Array.isArray(args) ? args : [];
++ if (op === 'db') return a[0] === 'saveChatMessage';
++ if (op === 'deployNotice') return true;
++ if (FRAME_OPS.has(op)) return isMessageFrame(a[a.length - 1]); // the frame is each of these ops' last argument
++ if (op === 'broadcastAllRaw') { try { return isMessageFrame(JSON.parse(String(a[0]))); } catch { return false; } }
++ return false;
++}
++
const REF_TTL_MS = 10 * 60 * 1000;
const REF_MIN = -(2 ** 40); // Live's placeholders are ≤ this
@@@ -163,7 +200,13 @@ function createBridge({ chatServer }) {
const boot = String((req.body && req.body.boot) || '').slice(0, 64) || 'unknown';
const ops = Array.isArray(req.body && req.body.ops) ? req.body.ops.slice(0, 500) : [];
const results = [];
++ const mayMessage = capabilities.check({ cap: req.principal && req.principal.cap }, MESSAGE_SEND);
for (const o of ops) {
++ if (!mayMessage.allowed && sendsMessage(String(o.op || ''), o.args)) {
++ console.warn(`[Bridge] ${o.op}${o.op === 'db' && Array.isArray(o.args) ? `.${o.args[0]}` : ''} refused: ${req.principal && req.principal.sub} lacks ${MESSAGE_SEND}`);
++ results.push({ seq: o.seq, ok: false, code: mayMessage.code || 'capability.denied', error: `${MESSAGE_SEND} not granted` });
++ continue;
++ }
try {
const result = await runOp(String(o.op || ''), o.args, boot, typeof o.ref === 'number' ? o.ref : null, typeof o.key === 'string' ? o.key.slice(0, 80) : null);
results.push({ seq: o.seq, ok: true, result });
@@@ -200,4 +243,4 @@ function createBridge({ chatServer }) {
return router;
}
--module.exports = { createBridge, DB_OPS, SERVER_OPS };
++module.exports = { createBridge, DB_OPS, SERVER_OPS, MESSAGE_SEND, sendsMessage };
ddiff --git a/test/bridge.test.js b/test/bridge.test.js
iindex c922674..52d09b4 100644
---- a/test/bridge.test.js
++++ b/test/bridge.test.js
@@@ -4,7 +4,8 @@
* service-token guard, forwarded chat writes with placeholder ids mapped for later ops of the same
* Live boot, idempotent retries, only allow-listed writes, pushes to browsers (broadcasts, DMs with
* the participant check, disconnects, user updates with the stored-name rewrite, deploy notices),
-- * and the presence snapshot Live's synchronous reads use.
++ * and the presence snapshot Live's synchronous reads use. Ops that send a chat message also need
++ * chat.message.send, for service and app principals alike.
*/
const assert = require('assert');
const { boot, suite } = require('./helpers');
@@@ -18,7 +19,7 @@ const calls = (ops, { token = BRIDGE, boot: b = 'live-boot-1', headers } = {}) =
t('boot', async () => {
h = await boot();
-- BRIDGE = h.serviceToken(['chat.live_bridge.write']);
++ BRIDGE = h.serviceToken(['chat.live_bridge.write', 'chat.message.send']);
streamer = h.addUser('streamer', { role: 'streamer' });
viewer = h.addUser('viewer');
channelId = h.addChannel(streamer.id);
@@@ -40,6 +41,50 @@ t('only Live’s service token, only from loopback', async () => {
assert.strictEqual((await h.http('GET', '/internal/live/presence', { token: BRIDGE })).status, 403);
});
++t('sending a chat message needs chat.message.send; without it only those ops are refused', async () => {
++ const APP = 'app:app_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3';
++ const before = h.db.get('SELECT COUNT(*) AS n FROM chat_messages').n;
++ const outboxBefore = h.db.get('SELECT COUNT(*) AS n FROM events_outbox').n;
++ const commit = { hash: 'f'.repeat(40), short: 'fffffff', date: '2026-09-22T10:00:00Z', subject: 'denied deploy' };
++ const sends = [
++ { op: 'db', ref: REF - 50, key: 'live:denied:1', args: ['saveChatMessage', { stream_id: streamId, username: 'Bot', message: 'denied insert', message_type: 'chat' }] },
++ { op: 'broadcastToStream', args: [streamId, { type: 'chat', id: 1, username: 'Bot', message: 'denied line' }] },
++ { op: 'forwardToGlobal', args: [streamId, { type: 'chat', id: 1, username: 'Bot', message: 'denied line' }] },
++ { op: 'broadcastAllRaw', args: [JSON.stringify({ type: 'chat', id: 1, username: 'Bot', message: 'denied line' })] },
++ { op: 'sendDm', args: [viewer.id, { type: 'dm', message: { id: 1, content: 'denied dm' } }] },
++ { op: 'deployNotice', args: [[commit]] },
++ ];
++ const others = [
++ { op: 'broadcastToStream', args: [streamId, { type: 'system', message: 'not a message send' }] },
++ { op: 'sendDm', args: [viewer.id, { type: 'call-ring', from: 'denied-test' }] },
++ ];
++ for (const [who, token] of [['service', h.serviceToken(['chat.live_bridge.write'])], ['app', h.serviceToken(['chat.live_bridge.write'], { sub: APP })]]) {
++ const r = await calls([...sends, ...others], { token });
++ assert.strictEqual(r.status, 200, r.text);
++ const res = r.body.results;
++ for (const x of res.slice(0, sends.length)) {
++ assert.strictEqual(x.ok, false, `${who}: seq ${x.seq} should be refused`);
++ assert.strictEqual(x.code, 'capability.denied');
++ assert.match(x.error, /chat\.message\.send/);
++ }
++ assert.ok(res.slice(sends.length).every((x) => x.ok), `${who}: other ops still run ${JSON.stringify(res)}`);
++ await viewerWs.next((m) => m.type === 'system' && m.message === 'not a message send');
++ await viewerWs.next((m) => m.type === 'call-ring' && m.from === 'denied-test');
++ }
++ const denied = (m) => /denied (insert|line|dm|deploy)/.test(JSON.stringify(m));
++ assert.ok(await viewerWs.none(denied), 'no refused message reached the stream room');
++ assert.ok(await globalWs.none(denied), 'no refused message reached global chat');
++ assert.strictEqual(h.db.get('SELECT COUNT(*) AS n FROM chat_messages').n, before, 'nothing persisted');
++ assert.strictEqual(h.db.get('SELECT COUNT(*) AS n FROM events_outbox').n, outboxBefore, 'no chat.message.created');
++ assert.strictEqual(h.db.get('SELECT COUNT(*) AS n FROM bridge_applied WHERE key = ?', ['live:denied:1']).n, 0, 'a refused write is not marked applied');
++ // The capability alone is not enough either: the bridge capability is still required.
++ assert.strictEqual((await calls(sends.slice(0, 1), { token: h.serviceToken(['chat.message.send']) })).status, 403);
++ // An app principal holding both may send.
++ const ok = await calls([{ op: 'broadcastToStream', args: [streamId, { type: 'chat', id: 2, username: 'App', message: 'app line' }] }], { token: h.serviceToken(['chat.live_bridge.write', 'chat.message.send'], { sub: APP }) });
++ assert.ok(ok.body.results[0].ok, ok.text);
++ await viewerWs.next((m) => m.type === 'chat' && m.message === 'app line');
++});
++
let realId;
t('a forwarded insert gets its real id, and later ops of the boot are rewritten', async () => {
const r = await calls([
ddiff --git a/test/helpers.js b/test/helpers.js
iindex 67ab4c1..41242ac 100644
---- a/test/helpers.js
++++ b/test/helpers.js
@@@ -42,7 +42,7 @@ async function boot({ env = {} } = {}) {
const { serviceAuth } = require('openvibe-contracts');
let jti = 0;
const serviceToken = (cap, { aud = 'openvibe.chat', sub = 'svc:live' } = {}) =>
-- serviceAuth.signServiceToken({ iss: ISS, sub, actor_type: 'service', aud: [aud], cap, iat: now(), exp: now() + 300, jti: `tok_test_${++jti}` }, keys.privateKey);
++ serviceAuth.signServiceToken({ iss: ISS, sub, actor_type: sub.startsWith('app:') ? 'app' : 'service', aud: [aud], cap, iat: now(), exp: now() + 300, jti: `tok_test_${++jti}` }, keys.privateKey);
// ── Stub Network ──
const tokenRequests = [];