openvibers/openvibe.chat Commits
Implement the additive Chat-owned ingress described by Live T3… (#16)
Changes
@@@ -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",@@@ -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 neversaved.--`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 DMsto `messages` (with `role`/`profile_color` etc. in `frame`), card, sound, media-queue, redemption@@@ -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",@@@ -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",@@@ -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));});@@@ -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', asassert.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);
@@ -7,8 +7,8 @@
@@ -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)",
"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,
"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)",
"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": [
"capabilitiesUsed": [
"live.chat_context.read",
"live.chat_context.read",
"live.chat_effects.write",
"live.chat_effects.write",
@@ -30,7 +30,7 @@
@@ -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)",
"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,
"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",
"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": [
"features": [
"Live's global, stream and channel chat (authority since 2026-09-23, Live keeps a read mirror)",
"Live's global, stream and channel chat (authority since 2026-09-23, Live keeps a read mirror)",
"DMs and group DMs, participants only",
"DMs and group DMs, participants only",
@@ -15,9 +15,9 @@ a stream that does not exist is 404: do not retry a 4xx.
@@ -15,9 +15,9 @@ a stream that does not exist is 404: do not retry a 4xx.
| Route | Capability | Body and result |
| 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/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`. |
| `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.
`/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
@@ -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
`source_platform: 'news'` and `system` (boolean); it targets a `stream` and is never
saved.
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
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
to `messages` (with `role`/`profile_color` etc. in `frame`), card, sound, media-queue, redemption
@@ -16,7 +16,7 @@
@@ -16,7 +16,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"multer": "^2.0.2",
"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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.5.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -1294,9 +1294,9 @@
@@ -1294,9 +1294,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"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",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -29,7 +29,7 @@
@@ -29,7 +29,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"multer": "^2.0.2",
"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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.5.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -292,7 +292,8 @@ function validateModeration(b) {
@@ -292,7 +292,8 @@ function validateModeration(b) {
// chat_messages.deleted_by is text: Live sends the moderator's id or username.
// 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.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 === '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 === '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 (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');
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) {
@@ -358,18 +359,12 @@ async function deliverInvalidate(chatServer, b) {
if (b.bans) await ctx.invalidateBans();
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 }) {
function createInternalIngress({ chatServer }) {
const r = express.Router();
const r = express.Router();
r.post('/messages', serviceAuth.guard('chat.message.send'), endpoint('messages', validateMessage, writeMessage, (b, result) => deliverMessage(chatServer, b, result)));
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) => {
r.get('/presence', serviceAuth.guard('chat.presence.read'), async (req, res) => {
res.json(await require('./presence').snapshot(chatServer));
res.json(await require('./presence').snapshot(chatServer));
});
});
@@ -6,8 +6,8 @@ const { boot, suite } = require('./helpers');
@@ -6,8 +6,8 @@ const { boot, suite } = require('./helpers');
const t = suite('chat-ingress');
const t = suite('chat-ingress');
let h, streamer, viewer, streamId, channelId, room, globalRoom;
let h, streamer, viewer, streamId, channelId, room, globalRoom;
const CAPS = {
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) =>
const post = (family, body, token = h.serviceToken([CAPS[family]]), headers) =>
h.http('POST', `/internal/chat/${family}`, { body, token, 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 () => {
@@ -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')).status, 401);
assert.strictEqual((await h.http('GET', '/internal/chat/presence', { token: h.serviceToken(['chat.message.send']) })).status, 403);
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']) {
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.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);
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
@@ -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('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('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: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);
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 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);
const sent = await post('messages', dmBody);