openvibers/openvibe.live Commits
Live's staff actions reach the network moderation audit log (ADR-022, WS-D task 1, Contracts 0.46.0): logModerationAction writes live.moderation.action to the outbox in the same transaction as the row (actor = the staff member's subject, target subject when linked); tidying one's own messages, configuring one's own channel or acting on oneself stays local. Browser smoke covers /search
Changes
@@@ -25,7 +25,7 @@"multer": "^1.4.5-lts.1","node-media-server": "^2.7.1","onnxruntime-node": "^1.20.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.45.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.46.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.9.1","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0","sanitize-html": "^2.17.7",@@@ -2814,9 +2814,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.45.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.45.0",-- "integrity": "sha512-6JJL9gHKd+gzowNzUls7l5nV0awR/KmEXxaxUaxzUPZilVFlDSM6yFpXMjFp+pDrUXDHYI5kfMevhpI0GEBFEA==",++ "version": "0.46.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.46.0",++ "integrity": "sha512-qCCtl46rThCNVjJQLz9pN64ufXqNDKgFc7g41loL7si/EzSScaO79FbJSaM1m4rt6hcMfQvOzerwOaRpsIczkg==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -43,7 +43,7 @@"multer": "^1.4.5-lts.1","node-media-server": "^2.7.1","onnxruntime-node": "^1.20.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.45.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.46.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.9.1","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0","sanitize-html": "^2.17.7",@@@ -6700,12 +6700,57 @@ function recordFirstChat(chatterKey, channelUserId) {/*** Log a moderation action for auditing.* Used by canvas, chat moderation, bans, etc.++ *++ * A staff or channel-moderator action also goes to OpenVibe.Network's moderation audit log as++ * live.moderation.action (Contracts 0.46.0, ADR-022), written to the outbox in the same transaction++ * as the row. Someone tidying their own messages (self_*), configuring their own channel, or acting++ * on themselves is not moderation and stays local.*/function logModerationAction({ scope_type, scope_id, actor_user_id, target_user_id, action_type, details }) {-- return run(`-- INSERT INTO moderation_actions (scope_type, scope_id, actor_user_id, target_user_id, action_type, details)-- VALUES (?, ?, ?, ?, ?, ?)-- `, [scope_type || 'site', scope_id || null, actor_user_id || null, target_user_id || null, action_type, JSON.stringify(details || {})]);++ const d = getDb();++ const row = [scope_type || 'site', scope_id || null, actor_user_id || null, target_user_id || null, action_type, JSON.stringify(details || {})];++ let announced = false;++ const info = d.transaction(() => {++ const r = d.prepare(`++ INSERT INTO moderation_actions (scope_type, scope_id, actor_user_id, target_user_id, action_type, details)++ VALUES (?, ?, ?, ?, ?, ?)++ `).run(...row);++ announced = _announceModerationAction(Number(r.lastInsertRowid), { scope_type: row[0], scope_id: row[1], actor_user_id: row[2], target_user_id: row[3], action_type, details });++ return r;++ })();++ if (announced) require('../events/stream-events').kick();++ return info;++}++++function _announceModerationAction(id, a) {++ const type = String(a.action_type || '');++ if (!type || type.startsWith('self_') || type === 'channel_settings_update') return false;++ if (a.actor_user_id != null && a.actor_user_id === a.target_user_id) return false;++ const events = require('../events/stream-events');++ if (!events.status().enabled) return false;++ const subjectOf = (uid) => {++ if (uid == null) return null;++ const r = getDb().prepare("SELECT subject_id FROM linked_accounts WHERE service = 'network' AND user_id = ?").get(uid);++ return r && /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(r.subject_id || '') ? r.subject_id : null;++ };++ const actorSubject = subjectOf(a.actor_user_id);++ const int = (v) => (v == null || v === '' || !Number.isSafeInteger(Number(v)) ? null : Number(v));++ const scopeId = a.scope_id == null || a.scope_id === '' ? null : (Number.isSafeInteger(Number(a.scope_id)) ? Number(a.scope_id) : String(a.scope_id).slice(0, 128));++ const details = a.details && typeof a.details === 'object' && !Array.isArray(a.details) ? a.details : {};++ events.enqueue({++ event_type: 'live.moderation.action',++ actor: actorSubject ? { type: 'user', id: actorSubject } : { type: 'service', id: 'live' },++ subject: { type: 'moderation_action', id: String(id) },++ visibility: 'internal',++ priority: 'important',++ payload: {++ action_id: id, action_type: type.slice(0, 64), scope_type: String(a.scope_type || 'site').slice(0, 32), scope_id: scopeId,++ actor_user_id: int(a.actor_user_id), actor_subject: actorSubject,++ target_user_id: int(a.target_user_id), target_subject: subjectOf(a.target_user_id),++ details,++ },++ });++ return true;}/**@@@ -24,7 +24,7 @@ const ROUTES_ONLY = process.argv.includes('--routes-only');const SETTLE = Number(process.env.SETTLE || 3500);const ROUTES = [-- ['/', 'page-home'], ['/content', 'page-content'], ['/moments', 'page-moments'],++ ['/', 'page-home'], ['/content', 'page-content'], ['/moments', 'page-moments'], ['/search?q=minecraft', 'page-search'],// The old list pages are the Content feed with a filter.['/vods', 'page-content'], ['/clips', 'page-content'], ['/pastes', 'page-content'],['/chat', 'page-chat'], ['/arena', 'page-arena'], ['/broadcast', 'page-broadcast'], ['/documentation', 'page-documentation'],nnew file mode 100644@@@ -0,0 +1,86 @@++'use strict';++// Live's staff actions reach OpenVibe.Network's moderation audit log (ADR-022, WS-D task 1, Contracts++// 0.46.0): logModerationAction writes live.moderation.action to the outbox in the same transaction as the++// row (valid against the contract, actor = the staff member's subject); tidying one's own messages,++// configuring one's own channel and acting on oneself stay local; with the outbox off the row is still++// written.++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-live-modevents-'));++process.env.DB_PATH = path.join(tmp, 'live.db');++const quiet = console.log;++console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };++console.warn = () => {};++++const published = [];++const stub = http.createServer((req, res) => {++ let body = '';++ req.on('data', (c) => { body += c; });++ req.on('end', () => {++ res.setHeader('Content-Type', 'application/json');++ if (req.url === '/oauth/token') return res.end(JSON.stringify({ access_token: 'tok', token_type: 'Bearer', expires_in: 300 }));++ if (req.url === '/api/v1/events' && req.method === 'POST') {++ const parsed = JSON.parse(body);++ const list = parsed.events || [parsed];++ const { validate } = require('openvibe-contracts');++ const bad = list.find((e) => !validate('events.event-envelope@1', e).valid);++ if (bad) { res.statusCode = 422; return res.end(JSON.stringify({ code: 'events.invalid_envelope' })); }++ const results = list.map((e) => { published.push(e); return { event_id: e.event_id, seq: published.length, duplicate: false }; });++ return res.end(JSON.stringify(parsed.events ? { results } : results[0]));++ }++ res.statusCode = 404; res.end('{}');++ });++});++++(async () => {++ await new Promise((r) => stub.listen(0, '127.0.0.1', r));++ const base = `http://127.0.0.1:${stub.address().port}`;++ process.env.OV_NETWORK_INTERNAL_URL = base;++ const { validate } = require('openvibe-contracts');++ const db = require('../server/db/database');++ db.initDb();++ const d = db.getDb();++ d.prepare("INSERT INTO users (id, username, display_name, password_hash, role) VALUES (1, 'boss', 'Boss', 'x', 'admin'), (2, 'spammer', 'Spammer', 'x', 'user')").run();++ d.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (1, 'network', '10', 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ'), (2, 'network', '20', 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR')").run();++ const rows = () => d.prepare('SELECT COUNT(*) AS n FROM moderation_actions').get().n;++++ // Outbox off: the row is written, nothing is queued.++ db.logModerationAction({ scope_type: 'site', actor_user_id: 1, target_user_id: 2, action_type: 'site_ban', details: { reason: 'spam' } });++ assert.strictEqual(rows(), 1);++++ const streamEvents = require('../server/events/stream-events');++ const outbox = streamEvents.init({ eventsUrl: base, clientSecret: 's3cret', intervalMs: 50 });++ const r = db.logModerationAction({ scope_type: 'site', actor_user_id: 1, target_user_id: 2, action_type: 'global_ban', details: { reason: 'spam', ban_type: 'permanent' } });++ db.logModerationAction({ scope_type: 'stream', scope_id: '77', actor_user_id: 1, target_user_id: 2, action_type: 'stream_force_end', details: {} });++ db.logModerationAction({ scope_type: 'stream', scope_id: 5, actor_user_id: 2, target_user_id: 2, action_type: 'self_message_delete_all', details: {} });++ db.logModerationAction({ scope_type: 'channel', scope_id: 5, actor_user_id: 2, target_user_id: null, action_type: 'channel_settings_update', details: {} });++ db.logModerationAction({ scope_type: 'stream', scope_id: 5, actor_user_id: 2, target_user_id: 2, action_type: 'message_delete', details: {} });++ assert.strictEqual(rows(), 6, 'every action is still logged locally');++ await outbox.flush();++ const evs = published.filter((e) => e.event_type === 'live.moderation.action');++ assert.deepStrictEqual(evs.map((e) => e.payload.action_type), ['global_ban', 'stream_force_end'], 'only moderation of someone else');++ for (const e of evs) { const v = validate('live.moderation.action@1', e.payload); assert.ok(v.valid, JSON.stringify(v.errors)); }++ const [ban, end] = evs;++ assert.deepStrictEqual(ban.actor, { type: 'user', id: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ' });++ assert.deepStrictEqual(ban.subject, { type: 'moderation_action', id: String(r.lastInsertRowid) });++ assert.strictEqual(ban.payload.target_subject, 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR');++ assert.deepStrictEqual(ban.payload.details, { reason: 'spam', ban_type: 'permanent' });++ assert.strictEqual(ban.priority, 'important'); assert.strictEqual(ban.visibility, 'internal');++ assert.strictEqual(end.payload.scope_id, 77);++++ // A failed insert queues nothing (the event is in its transaction).++ const before = published.length;++ assert.throws(() => db.logModerationAction({ scope_type: 'site', actor_user_id: 1, target_user_id: 2, action_type: null }));++ await outbox.flush();++ assert.strictEqual(published.length, before);++++ outbox.stop && outbox.stop();++ stub.close();++ fs.rmSync(tmp, { recursive: true, force: true });++ quiet('moderation events: all checks passed');++ process.exit(0);++})().catch((err) => { console.error(err); process.exit(1); });
@@ -25,7 +25,7 @@
@@ -25,7 +25,7 @@
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
"node-media-server": "^2.7.1",
"node-media-server": "^2.7.1",
"onnxruntime-node": "^1.20.0",
"onnxruntime-node": "^1.20.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.45.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.46.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.9.1",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.9.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0",
"sanitize-html": "^2.17.7",
"sanitize-html": "^2.17.7",
@@ -2814,9 +2814,9 @@
@@ -2814,9 +2814,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.45.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.45.0",
- "integrity": "sha512-6JJL9gHKd+gzowNzUls7l5nV0awR/KmEXxaxUaxzUPZilVFlDSM6yFpXMjFp+pDrUXDHYI5kfMevhpI0GEBFEA==",
+ "version": "0.46.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.46.0",
+ "integrity": "sha512-qCCtl46rThCNVjJQLz9pN64ufXqNDKgFc7g41loL7si/EzSScaO79FbJSaM1m4rt6hcMfQvOzerwOaRpsIczkg==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -43,7 +43,7 @@
@@ -43,7 +43,7 @@
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
"node-media-server": "^2.7.1",
"node-media-server": "^2.7.1",
"onnxruntime-node": "^1.20.0",
"onnxruntime-node": "^1.20.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.45.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.46.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.9.1",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.9.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0",
"sanitize-html": "^2.17.7",
"sanitize-html": "^2.17.7",
@@ -6700,12 +6700,57 @@ function recordFirstChat(chatterKey, channelUserId) {
@@ -6700,12 +6700,57 @@ function recordFirstChat(chatterKey, channelUserId) {
/**
/**
* Log a moderation action for auditing.
* Log a moderation action for auditing.
* Used by canvas, chat moderation, bans, etc.
* Used by canvas, chat moderation, bans, etc.
+ *
+ * A staff or channel-moderator action also goes to OpenVibe.Network's moderation audit log as
+ * live.moderation.action (Contracts 0.46.0, ADR-022), written to the outbox in the same transaction
+ * as the row. Someone tidying their own messages (self_*), configuring their own channel, or acting
+ * on themselves is not moderation and stays local.
*/
*/
function logModerationAction({ scope_type, scope_id, actor_user_id, target_user_id, action_type, details }) {
function logModerationAction({ scope_type, scope_id, actor_user_id, target_user_id, action_type, details }) {
- return run(`
- INSERT INTO moderation_actions (scope_type, scope_id, actor_user_id, target_user_id, action_type, details)
- VALUES (?, ?, ?, ?, ?, ?)
- `, [scope_type || 'site', scope_id || null, actor_user_id || null, target_user_id || null, action_type, JSON.stringify(details || {})]);
+ const d = getDb();
+ const row = [scope_type || 'site', scope_id || null, actor_user_id || null, target_user_id || null, action_type, JSON.stringify(details || {})];
+ let announced = false;
+ const info = d.transaction(() => {
+ const r = d.prepare(`
+ INSERT INTO moderation_actions (scope_type, scope_id, actor_user_id, target_user_id, action_type, details)
+ VALUES (?, ?, ?, ?, ?, ?)
+ `).run(...row);
+ announced = _announceModerationAction(Number(r.lastInsertRowid), { scope_type: row[0], scope_id: row[1], actor_user_id: row[2], target_user_id: row[3], action_type, details });
+ return r;
+ })();
+ if (announced) require('../events/stream-events').kick();
+ return info;
+}
+
+function _announceModerationAction(id, a) {
+ const type = String(a.action_type || '');
+ if (!type || type.startsWith('self_') || type === 'channel_settings_update') return false;
+ if (a.actor_user_id != null && a.actor_user_id === a.target_user_id) return false;
+ const events = require('../events/stream-events');
+ if (!events.status().enabled) return false;
+ const subjectOf = (uid) => {
+ if (uid == null) return null;
+ const r = getDb().prepare("SELECT subject_id FROM linked_accounts WHERE service = 'network' AND user_id = ?").get(uid);
+ return r && /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(r.subject_id || '') ? r.subject_id : null;
+ };
+ const actorSubject = subjectOf(a.actor_user_id);
+ const int = (v) => (v == null || v === '' || !Number.isSafeInteger(Number(v)) ? null : Number(v));
+ const scopeId = a.scope_id == null || a.scope_id === '' ? null : (Number.isSafeInteger(Number(a.scope_id)) ? Number(a.scope_id) : String(a.scope_id).slice(0, 128));
+ const details = a.details && typeof a.details === 'object' && !Array.isArray(a.details) ? a.details : {};
+ events.enqueue({
+ event_type: 'live.moderation.action',
+ actor: actorSubject ? { type: 'user', id: actorSubject } : { type: 'service', id: 'live' },
+ subject: { type: 'moderation_action', id: String(id) },
+ visibility: 'internal',
+ priority: 'important',
+ payload: {
+ action_id: id, action_type: type.slice(0, 64), scope_type: String(a.scope_type || 'site').slice(0, 32), scope_id: scopeId,
+ actor_user_id: int(a.actor_user_id), actor_subject: actorSubject,
+ target_user_id: int(a.target_user_id), target_subject: subjectOf(a.target_user_id),
+ details,
+ },
+ });
+ return true;
}
}
/**
/**
@@ -24,7 +24,7 @@ const ROUTES_ONLY = process.argv.includes('--routes-only');
@@ -24,7 +24,7 @@ const ROUTES_ONLY = process.argv.includes('--routes-only');
const SETTLE = Number(process.env.SETTLE || 3500);
const SETTLE = Number(process.env.SETTLE || 3500);
const ROUTES = [
const ROUTES = [
- ['/', 'page-home'], ['/content', 'page-content'], ['/moments', 'page-moments'],
+ ['/', 'page-home'], ['/content', 'page-content'], ['/moments', 'page-moments'], ['/search?q=minecraft', 'page-search'],
// The old list pages are the Content feed with a filter.
// The old list pages are the Content feed with a filter.
['/vods', 'page-content'], ['/clips', 'page-content'], ['/pastes', 'page-content'],
['/vods', 'page-content'], ['/clips', 'page-content'], ['/pastes', 'page-content'],
['/chat', 'page-chat'], ['/arena', 'page-arena'], ['/broadcast', 'page-broadcast'], ['/documentation', 'page-documentation'],
['/chat', 'page-chat'], ['/arena', 'page-arena'], ['/broadcast', 'page-broadcast'], ['/documentation', 'page-documentation'],
new file mode 100644
new file mode 100644
@@ -0,0 +1,86 @@
@@ -0,0 +1,86 @@
+'use strict';
+// Live's staff actions reach OpenVibe.Network's moderation audit log (ADR-022, WS-D task 1, Contracts
+// 0.46.0): logModerationAction writes live.moderation.action to the outbox in the same transaction as the
+// row (valid against the contract, actor = the staff member's subject); tidying one's own messages,
+// configuring one's own channel and acting on oneself stay local; with the outbox off the row is still
+// written.
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+
+const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-live-modevents-'));
+process.env.DB_PATH = path.join(tmp, 'live.db');
+const quiet = console.log;
+console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
+console.warn = () => {};
+
+const published = [];
+const stub = http.createServer((req, res) => {
+ let body = '';
+ req.on('data', (c) => { body += c; });
+ req.on('end', () => {
+ res.setHeader('Content-Type', 'application/json');
+ if (req.url === '/oauth/token') return res.end(JSON.stringify({ access_token: 'tok', token_type: 'Bearer', expires_in: 300 }));
+ if (req.url === '/api/v1/events' && req.method === 'POST') {
+ const parsed = JSON.parse(body);
+ const list = parsed.events || [parsed];
+ const { validate } = require('openvibe-contracts');
+ const bad = list.find((e) => !validate('events.event-envelope@1', e).valid);
+ if (bad) { res.statusCode = 422; return res.end(JSON.stringify({ code: 'events.invalid_envelope' })); }
+ const results = list.map((e) => { published.push(e); return { event_id: e.event_id, seq: published.length, duplicate: false }; });
+ return res.end(JSON.stringify(parsed.events ? { results } : results[0]));
+ }
+ res.statusCode = 404; res.end('{}');
+ });
+});
+
+(async () => {
+ await new Promise((r) => stub.listen(0, '127.0.0.1', r));
+ const base = `http://127.0.0.1:${stub.address().port}`;
+ process.env.OV_NETWORK_INTERNAL_URL = base;
+ const { validate } = require('openvibe-contracts');
+ const db = require('../server/db/database');
+ db.initDb();
+ const d = db.getDb();
+ d.prepare("INSERT INTO users (id, username, display_name, password_hash, role) VALUES (1, 'boss', 'Boss', 'x', 'admin'), (2, 'spammer', 'Spammer', 'x', 'user')").run();
+ d.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (1, 'network', '10', 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ'), (2, 'network', '20', 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR')").run();
+ const rows = () => d.prepare('SELECT COUNT(*) AS n FROM moderation_actions').get().n;
+
+ // Outbox off: the row is written, nothing is queued.
+ db.logModerationAction({ scope_type: 'site', actor_user_id: 1, target_user_id: 2, action_type: 'site_ban', details: { reason: 'spam' } });
+ assert.strictEqual(rows(), 1);
+
+ const streamEvents = require('../server/events/stream-events');
+ const outbox = streamEvents.init({ eventsUrl: base, clientSecret: 's3cret', intervalMs: 50 });
+ const r = db.logModerationAction({ scope_type: 'site', actor_user_id: 1, target_user_id: 2, action_type: 'global_ban', details: { reason: 'spam', ban_type: 'permanent' } });
+ db.logModerationAction({ scope_type: 'stream', scope_id: '77', actor_user_id: 1, target_user_id: 2, action_type: 'stream_force_end', details: {} });
+ db.logModerationAction({ scope_type: 'stream', scope_id: 5, actor_user_id: 2, target_user_id: 2, action_type: 'self_message_delete_all', details: {} });
+ db.logModerationAction({ scope_type: 'channel', scope_id: 5, actor_user_id: 2, target_user_id: null, action_type: 'channel_settings_update', details: {} });
+ db.logModerationAction({ scope_type: 'stream', scope_id: 5, actor_user_id: 2, target_user_id: 2, action_type: 'message_delete', details: {} });
+ assert.strictEqual(rows(), 6, 'every action is still logged locally');
+ await outbox.flush();
+ const evs = published.filter((e) => e.event_type === 'live.moderation.action');
+ assert.deepStrictEqual(evs.map((e) => e.payload.action_type), ['global_ban', 'stream_force_end'], 'only moderation of someone else');
+ for (const e of evs) { const v = validate('live.moderation.action@1', e.payload); assert.ok(v.valid, JSON.stringify(v.errors)); }
+ const [ban, end] = evs;
+ assert.deepStrictEqual(ban.actor, { type: 'user', id: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ' });
+ assert.deepStrictEqual(ban.subject, { type: 'moderation_action', id: String(r.lastInsertRowid) });
+ assert.strictEqual(ban.payload.target_subject, 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR');
+ assert.deepStrictEqual(ban.payload.details, { reason: 'spam', ban_type: 'permanent' });
+ assert.strictEqual(ban.priority, 'important'); assert.strictEqual(ban.visibility, 'internal');
+ assert.strictEqual(end.payload.scope_id, 77);
+
+ // A failed insert queues nothing (the event is in its transaction).
+ const before = published.length;
+ assert.throws(() => db.logModerationAction({ scope_type: 'site', actor_user_id: 1, target_user_id: 2, action_type: null }));
+ await outbox.flush();
+ assert.strictEqual(published.length, before);
+
+ outbox.stop && outbox.stop();
+ stub.close();
+ fs.rmSync(tmp, { recursive: true, force: true });
+ quiet('moderation events: all checks passed');
+ process.exit(0);
+})().catch((err) => { console.error(err); process.exit(1); });