openvibers/openvibe.chat Commits

Implement the additive Chat-owned ingress described by Live T3… (#16)

OP OpenVibers committed 4 Oct 2026 · c88d5814d7 · 6 files, +29 −34

checks run by GitHub CI (test) · CI ✓ 3/3 · review SHIP by Codex GPT-6 Sol · 1 fix round

https://github.com/OpenVibers/OpenVibe.Chat/pull/16

Changes

ddiff --git a/STATUS.json b/STATUS.json
iindex 2167a28..a6fb7d1 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -7,8 +7,8 @@
"runtime": "node 22, express, ws, PostgreSQL through openvibe-sdk/db (DATABASE_URL via PgBouncer, DATABASE_DIRECT_URL for migrations/; embedded PGlite outside production), Valkey for the per-actor limits (VALKEY_URL); better-sqlite3 gone (plan T3)",
"port": 4400,
"domain": "openvibe.chat (Chat's own site: global chat, messages, rooms, settings; the service manifest records it live since 2026-09-24) and openvibe.live (/ws/chat, /api/chat/, /api/dm/, /api/tts/, /api/sounds via an nginx include in openvibe.live's vhost)",
-- "contracts": "openvibe-contracts v0.71.0",
-- "capabilities": ["chat.message.send", "chat.presence.read", "chat.live_bridge.write"],
++ "contracts": "openvibe-contracts v0.91.0",
++ "capabilities": ["chat.message.send", "chat.event.publish", "chat.moderation.write", "chat.cache.invalidate", "chat.presence.read", "chat.live_bridge.write"],
"capabilitiesUsed": [
"live.chat_context.read",
"live.chat_effects.write",
@@@ -30,7 +30,7 @@
"calls": "Live's call server, call routes and stream hooks ported to server/calls/ with a calls lifecycle table (WS-I task 1). Off unless CHAT_CALLS=1; the switch is CHAT_CALLS=1, Live CALLS_AUTHORITY=chat and the nginx include deploy/nginx/calls/openvibe.live-calls.locations.conf (docs/calls-cutover.md)",
"deployed": true,
"deployment": "Deployed with `sudo ovhost deploy chat`: /opt/openvibe.chat, unit openvibe-chat, 127.0.0.1:4400 (loopback); public through the openvibe.chat vhost and openvibe.live's nginx include for the chat paths; EVENTS_URL set (chat.message.created reaches OpenVibe.Events); restore drill passed 2026-09-23",
-- "updated": "2026-09-29",
++ "updated": "2026-10-04",
"features": [
"Live's global, stream and channel chat (authority since 2026-09-23, Live keeps a read mirror)",
"DMs and group DMs, participants only",
ddiff --git a/docs/chat-ingress.md b/docs/chat-ingress.md
iindex 5524d80..8189b7b 100644
---- a/docs/chat-ingress.md
++++ b/docs/chat-ingress.md
@@@ -15,9 +15,9 @@ a stream that does not exist is 404: do not retry a 4xx.
| Route | Capability | Body and result |
| --- | --- | --- |
| `POST /internal/chat/messages` | `chat.message.send` | Chat message fields (`stream_id`, `channel_user_id`, `user_id`, `anon_id`, `username`, `message`, `message_type`, `is_global`, `reply_to_id`, `source_platform`, `metadata`), optional `mirror`, `tts:{voice,identity_key,key}`, `auto_delete_at`, and typed chat-frame fields in `frame` (`role`: `admin`/`global_mod`/`streamer`/`user`/`anon`/`external`, `profile_color`: hex, `avatar_url`, `is_ai`/`is_bot`/`filtered`: booleans, `core_username`, `display_name`); the broadcast carries `reply_to_id`, `is_global`, `auto_delete_at` and `metadata` from the body. Returns `{ok,id}`. With no TTS key, Chat uses `m<real message id>`. `dm:{to_user_id}` with `user_id` persists and delivers a direct message (the same `dm` frame as `POST /api/dm`, with the sender looked up by id), returning `{ok,id,conversation_id,to_user_id}`; a blank message is 400. Chat records the first chat in the channel (the welcome check) under `first_chat_key` when given (`user:<id>`, `anon:<anonId>` or `ext:<prefixed username>`), otherwise for a `chat` line only: `ext:<username>` with `source_platform`, else `user:<user_id>`, else `anon:<anon_id>`. |
--| `POST /internal/chat/events` | `chat.live_bridge.write` | `{key,target:{kind,id?},frame:{type,...}}`, returns `{ok:true}`. Target kinds: `stream`, `channel`, `owner-streams`, `user` (positive `id`), `global`, `all` (no `id`). Only the event frame types allowlisted in `internal-ingress.js` are accepted; `dm` and delete frames are refused, and `chat` only as the news card. |
--| `POST /internal/chat/moderation` | `chat.live_bridge.write` | `{key,action,...}`; actions: `delete-message`, `delete-user-messages`, `delete-anon-messages`, `delete-relay-messages`, `delete-by-range`, `review-pending-ip`, `approve-ip-messages`, `deny-ip-messages`, `relay-hide`, `relay-unhide`, `relay-record`, `tts-voice-override`, `disconnect`, `log`. Deletes return `ids` and broadcast `delete-messages` to every affected surface. |
--| `POST /internal/chat/invalidate` | `chat.live_bridge.write` | `{key,user?,user_data?,approvals?,bans?,channel?}`; a cache hint, returns `{ok:true}`. Include a typed `user_data` object when Live edited the account, so Chat updates sockets and stored message names. |
++| `POST /internal/chat/events` | `chat.event.publish` | `{key,target:{kind,id?},frame:{type,...}}`, returns `{ok:true}`. Target kinds: `stream`, `channel`, `owner-streams`, `user` (positive `id`), `global`, `all` (no `id`). Only the event frame types allowlisted in `internal-ingress.js` are accepted; `dm` and delete frames are refused, and `chat` only as the news card. |
++| `POST /internal/chat/moderation` | `chat.moderation.write` | `{key,action,...}`; actions: `delete-message`, `delete-user-messages`, `delete-anon-messages`, `delete-relay-messages`, `delete-by-range`, `review-pending-ip`, `approve-ip-messages`, `deny-ip-messages`, `relay-hide`, `relay-unhide`, `relay-record`, `tts-voice-override`, `disconnect`, `log`. Deletes return `ids` and broadcast `delete-messages` to every affected surface. |
++| `POST /internal/chat/invalidate` | `chat.cache.invalidate` | `{key,user?,user_data?,approvals?,bans?,channel?}`; a cache hint, returns `{ok:true}`. Include a typed `user_data` object when Live edited the account, so Chat updates sockets and stored message names. |
| `GET /internal/chat/presence` | `chat.presence.read` | The same snapshot as `/internal/live/presence`. |
`/internal/chat/events` is a narrow transient event ingress, not a generic ChatServer call.
@@@ -35,16 +35,13 @@ that target a `user`; Chat sends them to that user's sockets and persists nothin
`source_platform: 'news'` and `system` (boolean); it targets a `stream` and is never
saved.
--`events`, `moderation` and `invalidate` take `chat.live_bridge.write`, the capability Live
--already holds for the same broadcasts, moderation writes and cache hints through
--`/internal/live/calls`, so Live repoints with the grants it has today (`chat.message.send`,
--`chat.presence.read`, `chat.live_bridge.write`). Narrower capabilities (`chat.event.publish`,
--`chat.moderation.write`, `chat.cache.invalidate`; proposals in `docs/capabilities-proposal/`)
--need the separate **OpenVibe.Contracts** repository to register them and add them to
--`manifests/services/chat.json` first: Chat's CI `contracts` step (`openvibe-contracts-check`) refuses a guard on
--a capability Contracts does not define. Then Chat switches the three guards, and Network grants
--`live chat.event.publish openvibe.chat`, `live chat.moderation.write openvibe.chat` and
--`live chat.cache.invalidate openvibe.chat` before that Chat release deploys.
++Before Live repoints, the separate **OpenVibe.Contracts** repository must register
++`chat.event.publish`, `chat.moderation.write`, and `chat.cache.invalidate` and add them to
++`manifests/services/chat.json`. Network's `principal_grants` and `DEFAULT_GRANTS` must grant
++`live chat.event.publish openvibe.chat`, `live chat.moderation.write openvibe.chat`, and
++`live chat.cache.invalidate openvibe.chat`. Keep the existing Live grants for
++`chat.message.send`, `chat.presence.read`, and `chat.live_bridge.write` until Live no longer calls
++the bridge. Chat's local proposals are in `docs/capabilities-proposal/`.
Then Live J2 replaces its bridge writer with a typed client: repoint message producers and persisted DMs
to `messages` (with `role`/`profile_color` etc. in `frame`), card, sound, media-queue, redemption
ddiff --git a/package-lock.json b/package-lock.json
iindex 3563f7f..a1a6d6d 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -16,7 +16,7 @@
"express-rate-limit": "^7.5.0",
"iovalkey": "^0.4.0",
"multer": "^2.0.2",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.80.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.91.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.5.0",
"pg": "^8.23.0",
@@@ -1294,9 +1294,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.80.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.80.0",
-- "integrity": "sha512-7RCNFKxFRey3PquBwyQkSu8Bu2q2Ek0yb248SJWaymcg/g9PrlStFVUDTLMgh8i7BZSaZSngj0nojpwYTLGYDQ==",
++ "version": "0.91.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.91.0",
++ "integrity": "sha512-Sg5WUFRcnH+5TJGHciFU++dwWqXjhcKngscmICWHV8eGrt8kgTZfH85EeWtlG1LjdIRlWW9h2wK0Wtd5n4OGWw==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex 5d6c2ba..d7e973a 100644
---- a/package.json
++++ b/package.json
@@@ -29,7 +29,7 @@
"express-rate-limit": "^7.5.0",
"iovalkey": "^0.4.0",
"multer": "^2.0.2",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.80.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.91.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.5.0",
"pg": "^8.23.0",
ddiff --git a/server/chat/internal-ingress.js b/server/chat/internal-ingress.js
iindex 6b53563..37b06d4 100644
---- a/server/chat/internal-ingress.js
++++ b/server/chat/internal-ingress.js
@@@ -292,7 +292,8 @@ function validateModeration(b) {
// chat_messages.deleted_by is text: Live sends the moderator's id or username.
if (b.deleted_by != null && !int(b.deleted_by) && !str(b.deleted_by, 120)) bad('Invalid deleted_by');
if (b.action === 'relay-hide' && b.reason != null && !str(b.reason, 500)) bad('Invalid reason');
-- if (b.action.startsWith('delete-') && b.action !== 'delete-by-range' && !b.id && !b.user_id && !str(b.anon_id, 80) && !str(b.username, 120)) bad('Missing delete subject');
++ const deleteSubject = { 'delete-message': () => int(b.id), 'delete-user-messages': () => int(b.user_id), 'delete-anon-messages': () => str(b.anon_id, 80), 'delete-relay-messages': () => str(b.username, 120) }[b.action];
++ if (deleteSubject && !deleteSubject()) bad('Missing delete subject');
if (b.action === 'delete-by-range' && (!str(b.from, 40) || !str(b.to, 40) || !Number.isFinite(Date.parse(b.from)) || !Number.isFinite(Date.parse(b.to)))) bad('Invalid time range');
if (b.action === 'review-pending-ip' && (!int(b.id) || !['approved', 'denied'].includes(b.status))) bad('Invalid review');
if (['approve-ip-messages', 'deny-ip-messages'].includes(b.action) && (!int(b.channel_id) || !isIP(b.ip))) bad('Invalid IP review');
@@@ -358,18 +359,12 @@ async function deliverInvalidate(chatServer, b) {
if (b.bans) await ctx.invalidateBans();
}
--// Until OpenVibe.Contracts registers chat.event.publish, chat.moderation.write and
--// chat.cache.invalidate (proposals in docs/capabilities-proposal/), events, moderation and cache
--// hints take chat.live_bridge.write: the capability Live already holds for the same broadcasts,
--// moderation writes and invalidations through /internal/live/calls, so the ingress grants nothing new.
--const LIVE_WRITE = 'chat.live_bridge.write';
--
function createInternalIngress({ chatServer }) {
const r = express.Router();
r.post('/messages', serviceAuth.guard('chat.message.send'), endpoint('messages', validateMessage, writeMessage, (b, result) => deliverMessage(chatServer, b, result)));
-- r.post('/events', serviceAuth.guard(LIVE_WRITE), endpoint('events', validateEvent, async () => ({}), (b) => deliverEvent(chatServer, b)));
-- r.post('/moderation', serviceAuth.guard(LIVE_WRITE), endpoint('moderation', validateModeration, writeModeration, (b, result) => deliverModeration(chatServer, b, result)));
-- r.post('/invalidate', serviceAuth.guard(LIVE_WRITE), endpoint('invalidate', validateInvalidate, async () => ({}), (b) => deliverInvalidate(chatServer, b)));
++ r.post('/events', serviceAuth.guard('chat.event.publish'), endpoint('events', validateEvent, async () => ({}), (b) => deliverEvent(chatServer, b)));
++ r.post('/moderation', serviceAuth.guard('chat.moderation.write'), endpoint('moderation', validateModeration, writeModeration, (b, result) => deliverModeration(chatServer, b, result)));
++ r.post('/invalidate', serviceAuth.guard('chat.cache.invalidate'), endpoint('invalidate', validateInvalidate, async () => ({}), (b) => deliverInvalidate(chatServer, b)));
r.get('/presence', serviceAuth.guard('chat.presence.read'), async (req, res) => {
res.json(await require('./presence').snapshot(chatServer));
});
ddiff --git a/test/chat-ingress.test.js b/test/chat-ingress.test.js
iindex a5e8c41..228e7bd 100644
---- a/test/chat-ingress.test.js
++++ b/test/chat-ingress.test.js
@@@ -6,8 +6,8 @@ const { boot, suite } = require('./helpers');
const t = suite('chat-ingress');
let h, streamer, viewer, streamId, channelId, room, globalRoom;
const CAPS = {
-- messages: 'chat.message.send', events: 'chat.live_bridge.write', moderation: 'chat.live_bridge.write',
-- invalidate: 'chat.live_bridge.write', presence: 'chat.presence.read',
++ messages: 'chat.message.send', events: 'chat.event.publish', moderation: 'chat.moderation.write',
++ invalidate: 'chat.cache.invalidate', presence: 'chat.presence.read',
};
const post = (family, body, token = h.serviceToken([CAPS[family]]), headers) =>
h.http('POST', `/internal/chat/${family}`, { body, token, headers });
@@@ -36,9 +36,10 @@ t('each route requires its own service capability and loopback', async () => {
}
assert.strictEqual((await h.http('GET', '/internal/chat/presence')).status, 401);
assert.strictEqual((await h.http('GET', '/internal/chat/presence', { token: h.serviceToken(['chat.message.send']) })).status, 403);
-- // A message sender cannot publish events, moderate or invalidate, nor a bridge writer send lines.
++ // A message sender cannot publish events, moderate or invalidate, nor a bridge writer use the narrow routes.
for (const family of ['events', 'moderation', 'invalidate']) {
assert.strictEqual((await post(family, {}, h.serviceToken(['chat.message.send']))).status, 403);
++ assert.strictEqual((await post(family, {}, h.serviceToken(['chat.live_bridge.write']))).status, 403);
}
assert.strictEqual((await post('messages', {}, h.serviceToken(['chat.live_bridge.write']))).status, 403);
});
@@@ -185,6 +186,8 @@ t('bad input and missing rows are refused with a 4xx, never a retryable 503', as
assert.strictEqual((await post('events', { key: 'ingress:4xx:alert', target: { kind: 'channel', id: streamer.id }, frame: { type: 'alert', streamerId: streamer.id, streamId: 'x', kind: 'goal' } })).status, 400);
assert.strictEqual((await post('messages', { key: 'ingress:4xx:line', stream_id: 987654, username: 'Bot', message: 'nowhere' })).status, 404);
assert.strictEqual((await post('moderation', { key: 'ingress:4xx:by', action: 'delete-message', id: 1, deleted_by: { id: 1 } })).status, 400);
++ assert.strictEqual((await post('moderation', { key: 'ingress:4xx:noid', action: 'delete-message', deleted_by: 'mod' })).status, 400);
++ assert.strictEqual((await post('moderation', { key: 'ingress:4xx:nouser', action: 'delete-user-messages', stream_id: streamId })).status, 400);
assert.strictEqual((await post('moderation', { key: 'ingress:4xx:reason', action: 'relay-hide', channel_id: channelId, platform: 'twitch', external_username: 'x', reason: 5 })).status, 400);
const dmBody = { key: 'ingress:4xx:dm', user_id: streamer.id, dm: { to_user_id: viewer.id }, username: streamer.username, message: 'soon gone' };
const sent = await post('messages', dmBody);