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

OP OpenVibers committed 25 Sept 2026 · cf3e516be2 · 5 files, +141 −10


Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex a6d2515e..9bf4f052 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex 6c405d0b..057faf9d 100644
---- a/package.json
++++ b/package.json
@@@ -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",
ddiff --git a/server/db/database.js b/server/db/database.js
iindex 29f19807..5cfd6015 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -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;
}
/**
ddiff --git a/test/browser/smoke.js b/test/browser/smoke.js
iindex 433682d5..09428aea 100644
---- a/test/browser/smoke.js
++++ b/test/browser/smoke.js
@@@ -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'],
ddiff --git a/test/moderation-events.test.js b/test/moderation-events.test.js
nnew file mode 100644
iindex 00000000..3411a2a9
---- /dev/null
++++ b/test/moderation-events.test.js
@@@ -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); });