openvibers/openvibe.network Commits

Moderation audit log (WS-D task 1): records live.moderation.action (Contracts 0.46.0; Live's staff actions outside chat) as service live; filters by action and a since/until range beside service, actor and target; format=csv exports up to 5,000 matching rows (formula-looking cells kept as text); the admin tab gains the filters, Live as its own service and an Export CSV button

OP OpenVibers committed 25 Sept 2026 · d3c370ec3b · 7 files, +92 −28


Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex ac52a098..ac6ad415 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -20,7 +20,7 @@
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.44.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",
"uuid": "^11.1.0",
@@@ -1465,9 +1465,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.44.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.44.0",
-- "integrity": "sha512-VMjnJSwU2dnk9KstCHQDKfY6UysSk2fWK1wdLp15Via49/hbeaXRTa4XIjO3d82fYizj0rJpi9GrK+AJRskjtQ==",
++ "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 b83c1c31..c22c4349 100644
---- a/package.json
++++ b/package.json
@@@ -23,7 +23,7 @@
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.44.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",
"uuid": "^11.1.0",
ddiff --git a/public/admin.html b/public/admin.html
iindex f2e9030a..22b4e21b 100644
---- a/public/admin.html
++++ b/public/admin.html
@@@ -388,11 +388,18 @@ body{min-height:100%;color:var(--text);font-family:'Inter',-apple-system,BlinkMa
<!-- Moderation log (ADR-022): every staff action any service reports, from /api/v1/staff/moderation-audit -->
<div class="section" id="sec-modlog">
-- <p class="muted" style="margin:0 0 10px">Staff actions reported by Chat and Live, Community, Tips and Billing. Each service enforces its own moderation; this is the record.</p>
-- <div style="display:flex;gap:8px;align-items:center;margin-bottom:10px">
-- <label for="modlog-service" class="muted" style="font-size:12px">Service</label>
-- <select id="modlog-service" onchange="loadModLog(true)"><option value="">All</option><option value="chat">Chat and Live</option><option value="community">Community</option><option value="tips">Tips</option><option value="billing">Billing</option></select>
-- </div>
++ <p class="muted" style="margin:0 0 10px">Staff actions reported by Chat, Live, Community, Tips and Billing. Each service enforces its own moderation; this is the record.</p>
++ <form id="modlog-filters" style="display:flex;flex-wrap:wrap;gap:8px;align-items:end;margin-bottom:10px" onsubmit="event.preventDefault(); loadModLog(true)">
++ <label class="muted" style="font-size:12px;display:flex;flex-direction:column;gap:2px">Service
++ <select id="modlog-service" onchange="loadModLog(true)"><option value="">All</option><option value="chat">Chat</option><option value="live">Live</option><option value="community">Community</option><option value="tips">Tips</option><option value="billing">Billing</option></select></label>
++ <label class="muted" style="font-size:12px;display:flex;flex-direction:column;gap:2px">Action<input id="modlog-action" placeholder="e.g. site_ban" maxlength="64" style="width:130px"></label>
++ <label class="muted" style="font-size:12px;display:flex;flex-direction:column;gap:2px">By (subject)<input id="modlog-actor" placeholder="usr_…" maxlength="64" style="width:150px"></label>
++ <label class="muted" style="font-size:12px;display:flex;flex-direction:column;gap:2px">Target<input id="modlog-target" placeholder="subject or id" maxlength="200" style="width:150px"></label>
++ <label class="muted" style="font-size:12px;display:flex;flex-direction:column;gap:2px">From<input id="modlog-since" type="date"></label>
++ <label class="muted" style="font-size:12px;display:flex;flex-direction:column;gap:2px">Until<input id="modlog-until" type="date"></label>
++ <button class="btn" type="submit">Filter</button>
++ <button class="btn" type="button" onclick="exportModLog()">Export CSV</button>
++ </form>
<div id="modlog-content"><div class="loading">Loading...</div></div>
<div style="display:flex;justify-content:center;margin-top:12px"><button id="modlog-more" class="btn" hidden onclick="loadModLog(false)">Load more</button></div>
</div>
@@@ -3554,14 +3561,36 @@ async function loadBotAnalysis() {
// ═══════════════════════════════════════════════════════════════
let auditOffset = 0;
let _modlogNext = null;
++// The filters as a query: dates are whole days (until includes its day).
++function modLogQuery() {
++ const q = new URLSearchParams();
++ const val = (id) => (document.getElementById(id)?.value || '').trim();
++ for (const [k, id] of [['service', 'modlog-service'], ['action', 'modlog-action'], ['actor', 'modlog-actor'], ['target', 'modlog-target']]) if (val(id)) q.set(k, val(id));
++ if (val('modlog-since')) q.set('since', `${val('modlog-since')}T00:00:00Z`);
++ if (val('modlog-until')) { const d = new Date(`${val('modlog-until')}T00:00:00Z`); d.setUTCDate(d.getUTCDate() + 1); q.set('until', d.toISOString()); }
++ return q;
++}
++async function exportModLog() {
++ const q = modLogQuery();
++ q.set('format', 'csv');
++ try {
++ const headers = token ? { Authorization: 'Bearer ' + token } : {};
++ const res = await fetch(`${API}/api/v1/staff/moderation-audit?${q}`, { headers, credentials: 'include' });
++ if (!res.ok) throw new Error(`export failed (${res.status})`);
++ const url = URL.createObjectURL(await res.blob());
++ const a = document.createElement('a');
++ a.href = url; a.download = `moderation-audit-${new Date().toISOString().slice(0, 10)}.csv`;
++ document.body.appendChild(a); a.click(); a.remove();
++ setTimeout(() => URL.revokeObjectURL(url), 5000);
++ } catch (err) { alert(err.message || 'Export failed'); }
++}
async function loadModLog(reset) {
const c = document.getElementById('modlog-content');
const more = document.getElementById('modlog-more');
-- const service = document.getElementById('modlog-service').value;
if (reset) { _modlogNext = null; c.innerHTML = '<div class="loading">Loading...</div>'; }
try {
-- const q = new URLSearchParams({ limit: '50' });
-- if (service) q.set('service', service);
++ const q = modLogQuery();
++ q.set('limit', '50');
if (!reset && _modlogNext) q.set('before', String(_modlogNext));
const data = await api(`/api/v1/staff/moderation-audit?${q}`);
const items = data.items || [];
ddiff --git a/server/admin/moderation-audit.js b/server/admin/moderation-audit.js
iindex f4ace00d..7561c775 100644
---- a/server/admin/moderation-audit.js
++++ b/server/admin/moderation-audit.js
@@@ -3,7 +3,8 @@
* The network's moderation audit log (ADR-022): every staff action any service reports as an event,
* in one place staff can read. Enforcement stays with each owner; this only records.
*
-- * chat.moderation.action Chat and Live moderation (Live's log goes through the chat bridge)
++ * chat.moderation.action chat moderation (Chat, and Live's chat bridge)
++ * live.moderation.action Live's staff actions outside chat: bans, force-ended streams, admin deletions
* community.moderation.action staff actions on other people's content in Community
* tips.interaction.moderated a paid message filtered, held, hidden or restored
* billing.staff.action a staff money action in the Billing console
@@@ -11,13 +12,14 @@
* Rows are written by the Events consumer (server/notifications/events-consumer.js) inside its inbox
* transaction, so a redelivery records nothing twice. GET /api/v1/staff/moderation-audit lists them for
* staff holding staff.moderation.logs (global moderators and admins), newest first, filtered by
-- * service, actor or target, paged by `before` (the last row's id).
++ * service, action, actor, target and a since/until range, paged by `before` (the last row's id);
++ * `format=csv` exports up to 5,000 matching rows.
*/
const express = require('express');
const { staff } = require('openvibe-contracts');
const { staffClaims } = require('../auth/staff-claims');
--const TOPICS = Object.freeze(['chat.moderation.action', 'community.moderation.action', 'tips.interaction.moderated', 'billing.staff.action']);
++const TOPICS = Object.freeze(['chat.moderation.action', 'live.moderation.action', 'community.moderation.action', 'tips.interaction.moderated', 'billing.staff.action']);
const str = (v, n) => (v == null || v === '' ? null : String(v).replace(/[\u0000-\u001f\u007f]/g, ' ').slice(0, n));
const obj = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : {});
const json = (v) => { const s = JSON.stringify(obj(v)); return s.length > 4000 ? JSON.stringify({ truncated: true }) : s; };
@@@ -52,9 +54,10 @@ function rowOf(event) {
const actorId = actor.type === 'user' ? actor.id : null;
const base = { event_id: event.event_id, occurred_at: str(event.occurred_at || event.timestamp, 40) || new Date().toISOString() };
switch (event.event_type) {
-- case 'chat.moderation.action': {
++ case 'chat.moderation.action':
++ case 'live.moderation.action': {
const d = obj(p.details);
-- return { ...base, service: 'chat', action: str(p.action_type, 64) || 'unknown', actor_subject: str(p.actor_subject || actorId, 64),
++ return { ...base, service: event.event_type === 'live.moderation.action' ? 'live' : 'chat', action: str(p.action_type, 64) || 'unknown', actor_subject: str(p.actor_subject || actorId, 64),
target_type: p.target_subject || p.target_user_id ? 'user' : null, target_id: str(p.target_subject || p.target_user_id, 128), target_subject: str(p.target_subject, 64),
scope: str(`${p.scope_type || 'site'}${p.scope_id != null ? `:${p.scope_id}` : ''}`, 128), reason: str(d.reason, 500), details: json(d) };
}
@@@ -90,15 +93,19 @@ function createModerationAudit(db) {
return ins.run(row).changes ? 'recorded' : 'ignored:duplicate';
}
-- function list({ service = null, actor = null, target = null, before = null, limit = 50 } = {}) {
++ function list({ service = null, action = null, actor = null, target = null, since = null, until = null, before = null, limit = 50, max = 200 } = {}) {
const where = [];
const args = [];
++ const day = (v) => { const d = new Date(String(v || '')); return Number.isNaN(d.getTime()) ? null : d.toISOString(); };
if (service) { where.push('service = ?'); args.push(String(service)); }
++ if (action) { where.push('action = ?'); args.push(String(action).slice(0, 64)); }
++ if (day(since)) { where.push('occurred_at >= ?'); args.push(day(since)); }
++ if (day(until)) { where.push('occurred_at < ?'); args.push(day(until)); }
if (actor) { where.push('actor_subject = ?'); args.push(String(actor)); }
if (target) { where.push('(target_id = ? OR target_subject = ?)'); args.push(String(target), String(target)); }
const b = parseInt(before, 10);
if (Number.isFinite(b) && b > 0) { where.push('id < ?'); args.push(b); }
-- const n = Math.min(Math.max(parseInt(limit, 10) || 50, 1), 200);
++ const n = Math.min(Math.max(parseInt(limit, 10) || 50, 1), max);
const rows = db.prepare(`SELECT * FROM moderation_audit ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY id DESC LIMIT ?`).all(...args, n + 1);
const more = rows.length > n;
const items = rows.slice(0, n).map((r) => ({ ...r, details: (() => { try { return JSON.parse(r.details); } catch { return {}; } })() }));
@@@ -111,7 +118,13 @@ function createModerationAudit(db) {
r.get('/', (req, res) => {
if (!req.user || !staff.can(staffClaims(req.user), 'staff.moderation.logs')) return res.status(403).json({ error: 'forbidden', detail: 'staff.moderation.logs required' });
res.set('Cache-Control', 'private, no-store');
-- res.json(list({ service: req.query.service, actor: req.query.actor, target: req.query.target, before: req.query.before, limit: req.query.limit }));
++ const filters = { service: req.query.service, action: req.query.action, actor: req.query.actor, target: req.query.target, since: req.query.since, until: req.query.until };
++ if (req.query.format === 'csv') {
++ const { items } = list({ ...filters, limit: CSV_MAX, max: CSV_MAX });
++ res.set('Content-Disposition', `attachment; filename="moderation-audit-${new Date().toISOString().slice(0, 10)}.csv"`);
++ return res.type('text/csv').send(toCsv(items));
++ }
++ res.json(list({ ...filters, before: req.query.before, limit: req.query.limit }));
});
return r;
}
@@@ -119,4 +132,16 @@ function createModerationAudit(db) {
return { record, list, router, TOPICS };
}
--module.exports = { createModerationAudit, rowOf, TOPICS };
++const CSV_MAX = 5000;
++const CSV_COLUMNS = ['id', 'occurred_at', 'service', 'action', 'actor_subject', 'target_type', 'target_id', 'target_subject', 'scope', 'reason', 'details'];
++// A cell starting with = + - @ is text, not a formula, when the file is opened in a spreadsheet.
++const cell = (v) => {
++ let s = v == null ? '' : typeof v === 'object' ? JSON.stringify(v) : String(v);
++ if (/^[=+\-@\t\r]/.test(s)) s = `'${s}`;
++ return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
++};
++function toCsv(items) {
++ return [CSV_COLUMNS.join(','), ...items.map((r) => CSV_COLUMNS.map((k) => cell(r[k])).join(','))].join('\r\n') + '\r\n';
++}
++
++module.exports = { createModerationAudit, rowOf, toCsv, TOPICS };
ddiff --git a/test/events-consumer.test.js b/test/events-consumer.test.js
iindex e518cb71..0ca3a060 100644
---- a/test/events-consumer.test.js
++++ b/test/events-consumer.test.js
@@@ -58,7 +58,7 @@ const rows = (userId) => db.prepare('SELECT * FROM notifications WHERE user_id =
(async () => {
// The subscriptions this consumer is meant for, and nothing else.
-- assert.deepStrictEqual(TOPICS, ['deals.watch.matched', 'trade.alert.triggered', 'live.stream.started', 'chat.moderation.action', 'community.moderation.action', 'tips.interaction.moderated', 'billing.staff.action']);
++ assert.deepStrictEqual(TOPICS, ['deals.watch.matched', 'trade.alert.triggered', 'live.stream.started', 'chat.moderation.action', 'live.moderation.action', 'community.moderation.action', 'tips.interaction.moderated', 'billing.staff.action']);
assert.deepStrictEqual(topicsFrom([]), TOPICS, 'scripts/subscribe-events.js subscribes every topic');
assert.deepStrictEqual(topicsFrom(['--topic', 'live.stream.started']), ['live.stream.started']);
assert.throws(() => topicsFrom(['--topic', 'live.*']));
ddiff --git a/test/moderation-audit.test.js b/test/moderation-audit.test.js
iindex a6036a00..55792951 100644
---- a/test/moderation-audit.test.js
++++ b/test/moderation-audit.test.js
@@@ -28,22 +28,32 @@ const ev = (type, payload, actor = { type: 'user', id: MOD }) => ({ event_id: id
const chat = ev('chat.moderation.action', { action_id: 5, action_type: 'ban', scope_type: 'channel', scope_id: '327', actor_user_id: 1, actor_subject: MOD, target_user_id: 44, target_subject: TARGET, details: { reason: 'spam' } });
const community = ev('community.moderation.action', { action: 'paste.deleted', target: { type: 'paste', id: 'k3f9Qa', owner_subject: TARGET }, actor_subject: MOD, reason: 'doxxing', details: {} });
const tips = ev('tips.interaction.moderated', { interaction_id: 'tint_01JAB3C4D5E6F7G8H9J0K1MNPQ', creator: { type: 'user', id: TARGET }, action: 'hidden', by: 'moderator', moderation_state: 'hidden', cancelled_effects: ['tts'] }, { type: 'service', id: 'tips' });
++ const live = ev('live.moderation.action', { action_id: 9, action_type: 'site_ban', scope_type: 'site', scope_id: null, actor_user_id: 1, actor_subject: MOD, target_user_id: 44, target_subject: TARGET, details: { reason: '=HYPERLINK("x")' } });
const billing = ev('billing.staff.action', { audit_id: 'sa_01JAB3C4D5E6F7G8H9J0K1MNPQ', action: 'cashout.approved', outcome: 'done', target: { type: 'cashout', id: 'co_1' }, reason: null, request_id: null, detail: { amount: 5 } });
-- for (const e of [chat, community, tips, billing]) assert.strictEqual(consumer.apply(e).outcome, 'recorded', e.event_type);
++ for (const e of [chat, community, tips, billing, live]) assert.strictEqual(consumer.apply(e).outcome, 'recorded', e.event_type);
assert.strictEqual(consumer.apply(chat).duplicate, true, 'a redelivery records nothing twice');
assert.strictEqual(db.prepare('SELECT COUNT(*) AS n FROM notifications').get().n, 0, 'audit events notify nobody');
const all = audit.list({});
-- assert.strictEqual(all.items.length, 4);
++ assert.strictEqual(all.items.length, 5);
++ const liveRow = all.items.find((r) => r.service === 'live');
++ assert.deepStrictEqual([liveRow.action, liveRow.scope, liveRow.target_subject], ['site_ban', 'site', TARGET]);
++ assert.strictEqual(audit.list({ action: 'site_ban' }).items.length, 1);
++ assert.strictEqual(audit.list({ since: '2000-01-01', until: '2001-01-01' }).items.length, 0);
++ assert.strictEqual(audit.list({ since: '2000-01-01' }).items.length, 5);
++ const csv = require('../server/admin/moderation-audit').toCsv(all.items);
++ assert.ok(csv.startsWith('id,occurred_at,service,action,'));
++ assert.ok(csv.includes(`"'=HYPERLINK(""x"")"`), 'a formula-looking cell is text');
++ assert.strictEqual(csv.trim().split('\r\n').length, 6);
const byService = Object.fromEntries(all.items.map((r) => [r.service, r]));
assert.deepStrictEqual([byService.chat.action, byService.chat.scope, byService.chat.target_subject, byService.chat.reason], ['ban', 'channel:327', TARGET, 'spam']);
assert.deepStrictEqual([byService.community.action, byService.community.target_type, byService.community.target_id, byService.community.reason], ['paste.deleted', 'paste', 'k3f9Qa', 'doxxing']);
assert.strictEqual(byService.tips.action, 'interaction.hidden');
assert.strictEqual(byService.billing.actor_subject, MOD);
assert.strictEqual(audit.list({ service: 'community' }).items.length, 1);
-- assert.strictEqual(audit.list({ actor: MOD }).items.length, 3, 'tips carries no moderator identity');
-- assert.strictEqual(audit.list({ target: TARGET }).items.length, 3);
++ assert.strictEqual(audit.list({ actor: MOD }).items.length, 4, 'tips carries no moderator identity');
++ assert.strictEqual(audit.list({ target: TARGET }).items.length, 4);
const page = audit.list({ limit: 2 });
assert.strictEqual(page.items.length, 2);
assert.strictEqual(audit.list({ limit: 2, before: page.next }).items.length, 2, 'paged by id');
ddiff --git a/test/registry-topics.test.js b/test/registry-topics.test.js
iindex a086c9bb..edc82a4f 100644
---- a/test/registry-topics.test.js
++++ b/test/registry-topics.test.js
@@@ -70,7 +70,7 @@ const rel = (contractsVersion, packages) => [200, { service: 'x', release: 'abcd
let r = await get('/api/v1/registry/topics');
assert.ok(r.body.topics.length >= produced.size, 'no longer empty');
r = await get('/api/v1/registry/topics?producer=live');
-- assert.deepStrictEqual(r.body.topics.map(t => t.topic).sort(), ['live.index_document.deleted', 'live.index_document.upserted', 'live.release.deployed', 'live.stream.ended', 'live.stream.started']);
++ assert.deepStrictEqual(r.body.topics.map(t => t.topic).sort(), ['live.index_document.deleted', 'live.index_document.upserted', 'live.moderation.action', 'live.release.deployed', 'live.stream.ended', 'live.stream.started']);
r = await get('/api/v1/registry/topics?consumer=network');
assert.deepStrictEqual(r.body.topics.map(t => t.topic).sort(), [...TOPICS].sort());
r = await get('/api/v1/registry/topics?prefix=media.vod.');