openvibers/openvibe.chat Commits

Chat rooms on the realtime plane (WS-I task 9, contracts 0.57.0): a message in a public room is announced as chat.room.message.created (visibility public) in the transaction that stores it; deleting one, or turning the room private, announces chat.room.message.deleted with a redaction so it stops being replayable; private rooms and DMs never leave Chat. Pins: openvibe-contracts 0.49.0 -> 0.57.0

OP OpenVibers committed 26 Sept 2026 · 593513b8fb · 4 files, +96 −7


Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex a185fb9..5c36fd9 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -16,7 +16,7 @@
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"multer": "^2.0.2",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.57.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.13.0",
"ws": "^8.18.0"
@@@ -1062,9 +1062,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.49.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",
-- "integrity": "sha512-lapsLoBm3ZpndgiOMELdCBh2PJtuuyqj6SUhQsfwJkxnLdQfyhXfRFgiuLh+iQrg4kqtG9611191Mp8zAfk7fQ==",
++ "version": "0.57.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.57.0",
++ "integrity": "sha512-6hXpTo0y1h7YFQgyxkkWIoSx4CqSPDlGAK617MV2SkaO01zVOWKSQ1wor7aFOvrv+7u7a3KsIak3sBznNc39Vw==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex 48465bb..8e1180f 100644
---- a/package.json
++++ b/package.json
@@@ -27,7 +27,7 @@
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"multer": "^2.0.2",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.57.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.13.0",
"ws": "^8.18.0"
ddiff --git a/server/rooms/rooms.js b/server/rooms/rooms.js
iindex 754fcf9..2518af0 100644
---- a/server/rooms/rooms.js
++++ b/server/rooms/rooms.js
@@@ -171,6 +171,43 @@ function rateOk(key, now = Date.now()) {
}
/** Post a message; returns the stored row (with the author's names) for broadcasting. */
++// ── The realtime plane (WS-I task 9, contracts 0.57.0) ──
++// A PUBLIC room's messages are announced as chat.room.message.created (visibility public), so browsers
++// subscribed to chat.room.* get them over Events realtime; private rooms and DMs never are. Deleting a
++// message, or turning the room private, announces chat.room.message.deleted with a redaction, so what
++// the room had published stops being replayable. Both are written in the transaction of the change.
++const REDACT_CHUNK = 500;
++function announceMessage(room, user, id, message, now) {
++ if (room.visibility !== 'public') return;
++ require('../events/outbox').enqueue({
++ event_type: 'chat.room.message.created',
++ visibility: 'public',
++ actorSubject: user.subject_id || null,
++ subject: { type: 'chat_room_message', id: String(id) },
++ payload: {
++ message_id: id,
++ room: { slug: room.slug, name: room.name },
++ user_subject: user.subject_id || null,
++ username: user.username || null,
++ display_name: user.display_name || user.username || null,
++ text: String(message).slice(0, 2000),
++ created_at: new Date(now).toISOString(),
++ },
++ });
++}
++function announceDeleted(room, ids) {
++ const list = [...new Set(ids.map(Number).filter((n) => Number.isInteger(n) && n > 0))];
++ for (let i = 0; i < list.length; i += REDACT_CHUNK) {
++ const part = list.slice(i, i + REDACT_CHUNK);
++ require('../events/outbox').enqueue({
++ event_type: 'chat.room.message.deleted',
++ visibility: 'public',
++ subject: { type: 'chat_room_message', id: String(part[0]) },
++ payload: { room: room.slug, message_ids: part, redacts: { subject_type: 'chat_room_message', subject_ids: part.map(String) } },
++ });
++ }
++}
++
function post(room, user, text, { now = Date.now() } = {}) {
const a = access(room, user);
if (!user) fail(401, 'rooms.sign_in', 'Sign in to chat');
@@@ -191,6 +228,7 @@ function post(room, user, text, { now = Date.now() } = {}) {
const r = db.run('INSERT INTO room_messages (room_id, user_id, subject_id, message) VALUES (?, ?, ?, ?)', [room.id, user.id, user.subject_id || null, message]);
db.run('UPDATE rooms SET message_count = message_count + 1, last_message_at = CURRENT_TIMESTAMP WHERE id = ?', [room.id]);
db.run('UPDATE room_members SET last_read_id = ? WHERE room_id = ? AND user_id = ?', [r.lastInsertRowid, room.id, user.id]);
++ announceMessage(room, user, Number(r.lastInsertRowid), message, now);
return Number(r.lastInsertRowid);
});
return messageById(id);
@@@ -223,7 +261,10 @@ function deleteMessage(room, user, messageId) {
const own = user && m.user_id === user.id;
const a = access(room, user);
if (!own && !a.moderate) fail(403, 'rooms.not_yours', 'Only the author or a room moderator can delete this');
-- db.run('UPDATE room_messages SET is_deleted = 1, deleted_by = ? WHERE id = ?', [user.id, m.id]);
++ db.transaction(() => {
++ db.run('UPDATE room_messages SET is_deleted = 1, deleted_by = ? WHERE id = ?', [user.id, m.id]);
++ if (room.visibility === 'public') announceDeleted(room, [m.id]);
++ });
if (!own) db.logModerationAction({ scope_type: 'room', scope_id: room.slug, actor_user_id: user.id, target_user_id: m.user_id, action_type: 'delete_message', details: { message_id: m.id } });
return m.id;
}
@@@ -256,7 +297,13 @@ function update(room, actor, { name, topic, visibility, slow_seconds } = {}) {
if (topic != null) next.topic = String(topic).replace(/\s+/g, ' ').trim().slice(0, 200) || null;
if (visibility != null) { if (!['public', 'private'].includes(visibility)) fail(422, 'rooms.visibility', 'Visibility is public or private'); next.visibility = visibility; }
if (slow_seconds != null) { const v = Number(slow_seconds); if (!Number.isInteger(v) || v < 0 || v > 600) fail(422, 'rooms.slow', 'Slow mode is 0 to 600 seconds'); next.slow_seconds = v; }
-- db.run('UPDATE rooms SET name = ?, topic = ?, visibility = ?, slow_seconds = ? WHERE id = ?', [next.name, next.topic, next.visibility, next.slow_seconds, room.id]);
++ db.transaction(() => {
++ db.run('UPDATE rooms SET name = ?, topic = ?, visibility = ?, slow_seconds = ? WHERE id = ?', [next.name, next.topic, next.visibility, next.slow_seconds, room.id]);
++ // Public → private: everything the room published stops being replayable on the realtime plane.
++ if (room.visibility === 'public' && next.visibility === 'private') {
++ announceDeleted(room, db.all('SELECT id FROM room_messages WHERE room_id = ? AND is_deleted = 0 ORDER BY id', [room.id]).map((r) => r.id));
++ }
++ });
return publicRoom(db.get('SELECT * FROM rooms WHERE id = ?', [room.id]));
}
ddiff --git a/test/rooms.test.js b/test/rooms.test.js
iindex eb3c490..60a8658 100644
---- a/test/rooms.test.js
++++ b/test/rooms.test.js
@@@ -106,4 +106,46 @@ t('slow mode and site bans', async () => {
assert.deepStrictEqual([r.status, r.body.code], [403, 'rooms.banned'], 'a site ban applies in rooms');
});
++t('realtime plane (WS-I task 9): public rooms announce messages and redactions, private rooms never do', async () => {
++ const { validate } = require('openvibe-contracts');
++ const dan = h.addUser('dan', { subject: ids.newId('user') });
++ h.ctx.upsertUser(h.live.users.get(dan.id));
++ const db = require('../server/db/database');
++ const since = (db.get('SELECT MAX(rowid) AS n FROM events_outbox') || {}).n || 0;
++ const newEvents = () => db.all('SELECT event FROM events_outbox WHERE rowid > ? ORDER BY rowid', [since]).map((r) => JSON.parse(r.event)).filter((e) => /^chat\.room\./.test(e.event_type));
++ let r = await api('POST', '/', dan, { name: 'Open Plaza' });
++ assert.strictEqual(r.status, 201, r.text);
++ r = await api('POST', '/open-plaza/messages', dan, { message: 'hello plaza' });
++ const msgId = r.body.message.id;
++ let ev = newEvents();
++ assert.strictEqual(ev.length, 1);
++ assert.deepStrictEqual([ev[0].event_type, ev[0].visibility, ev[0].subject], ['chat.room.message.created', 'public', { type: 'chat_room_message', id: String(msgId) }]);
++ assert.strictEqual(ev[0].payload.room.slug, 'open-plaza'); assert.strictEqual(ev[0].payload.text, 'hello plaza');
++ assert.ok(validate('events.event-envelope@1', ev[0]).valid);
++ const pv = validate('chat.room.message.created@1', ev[0].payload); assert.ok(pv.valid, JSON.stringify(pv.errors));
++
++ // A private room: nothing leaves Chat.
++ r = await api('POST', '/', dan, { name: 'Back Room', visibility: 'private' });
++ assert.strictEqual(r.status, 201, r.text);
++ await api('POST', '/back-room/messages', dan, { message: 'just us' });
++ assert.strictEqual(newEvents().length, 1, 'no event for a private room');
++
++ // Deleting a public message redacts it; turning the room private redacts the rest.
++ await api('POST', '/open-plaza/messages', dan, { message: 'second' });
++ await api('POST', '/open-plaza/messages', dan, { message: 'third' });
++ assert.strictEqual((await api('DELETE', `/open-plaza/messages/${msgId}`, dan)).status, 200);
++ ev = newEvents();
++ const del = ev.filter((e) => e.event_type === 'chat.room.message.deleted');
++ assert.strictEqual(del.length, 1);
++ assert.deepStrictEqual(del[0].payload.message_ids, [msgId]);
++ assert.deepStrictEqual(del[0].payload.redacts, { subject_type: 'chat_room_message', subject_ids: [String(msgId)] });
++ assert.ok(validate('chat.room.message.deleted@1', del[0].payload).valid);
++ r = await api('PATCH', '/open-plaza', dan, { visibility: 'private' });
++ assert.strictEqual(r.status, 200, r.text);
++ const last = newEvents().filter((e) => e.event_type === 'chat.room.message.deleted').pop();
++ assert.strictEqual(last.payload.message_ids.length, 2, 'the two remaining public messages are redacted');
++ await api('POST', '/open-plaza/messages', dan, { message: 'now private' });
++ assert.strictEqual(newEvents().filter((e) => e.event_type === 'chat.room.message.created').length, 3, 'nothing new once private');
++});
++
t.run(async () => { if (h && h.close) await h.close(); });