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
Changes
@@@ -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",@@@ -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"@@@ -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]));}@@@ -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(); });
@@ -16,7 +16,7 @@
@@ -16,7 +16,7 @@
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"multer": "^2.0.2",
"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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.13.0",
"ws": "^8.18.0"
"ws": "^8.18.0"
@@ -1062,9 +1062,9 @@
@@ -1062,9 +1062,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"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",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -27,7 +27,7 @@
@@ -27,7 +27,7 @@
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"multer": "^2.0.2",
"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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.13.0",
"ws": "^8.18.0"
"ws": "^8.18.0"
@@ -171,6 +171,43 @@ function rateOk(key, now = Date.now()) {
@@ -171,6 +171,43 @@ function rateOk(key, now = Date.now()) {
}
}
/** Post a message; returns the stored row (with the author's names) for broadcasting. */
/** 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() } = {}) {
function post(room, user, text, { now = Date.now() } = {}) {
const a = access(room, user);
const a = access(room, user);
if (!user) fail(401, 'rooms.sign_in', 'Sign in to chat');
if (!user) fail(401, 'rooms.sign_in', 'Sign in to chat');
@@ -191,6 +228,7 @@ function post(room, user, text, { now = Date.now() } = {}) {
@@ -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]);
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 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]);
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 Number(r.lastInsertRowid);
});
});
return messageById(id);
return messageById(id);
@@ -223,7 +261,10 @@ function deleteMessage(room, user, messageId) {
@@ -223,7 +261,10 @@ function deleteMessage(room, user, messageId) {
const own = user && m.user_id === user.id;
const own = user && m.user_id === user.id;
const a = access(room, user);
const a = access(room, user);
if (!own && !a.moderate) fail(403, 'rooms.not_yours', 'Only the author or a room moderator can delete this');
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 } });
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;
return m.id;
}
}
@@ -256,7 +297,13 @@ function update(room, actor, { name, topic, visibility, slow_seconds } = {}) {
@@ -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 (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 (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; }
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]));
return publicRoom(db.get('SELECT * FROM rooms WHERE id = ?', [room.id]));
}
}
@@ -106,4 +106,46 @@ t('slow mode and site bans', async () => {
@@ -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');
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(); });
t.run(async () => { if (h && h.close) await h.close(); });