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
Changes
@@@ -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",@@@ -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",@@@ -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 || [];@@@ -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 };@@@ -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.*']));@@@ -28,22 +28,32 @@ const ev = (type, payload, actor = { type: 'user', id: MOD }) => ({ event_id: idconst 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');@@@ -70,7 +70,7 @@ const rel = (contractsVersion, packages) => [200, { service: 'x', release: 'abcdlet 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.');
@@ -20,7 +20,7 @@
@@ -20,7 +20,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"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-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",
"uuid": "^11.1.0",
"uuid": "^11.1.0",
@@ -1465,9 +1465,9 @@
@@ -1465,9 +1465,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"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",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -23,7 +23,7 @@
@@ -23,7 +23,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"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-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",
"uuid": "^11.1.0",
"uuid": "^11.1.0",
@@ -388,11 +388,18 @@ body{min-height:100%;color:var(--text);font-family:'Inter',-apple-system,BlinkMa
@@ -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 -->
<!-- Moderation log (ADR-022): every staff action any service reports, from /api/v1/staff/moderation-audit -->
<div class="section" id="sec-modlog">
<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 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 style="display:flex;justify-content:center;margin-top:12px"><button id="modlog-more" class="btn" hidden onclick="loadModLog(false)">Load more</button></div>
</div>
</div>
@@ -3554,14 +3561,36 @@ async function loadBotAnalysis() {
@@ -3554,14 +3561,36 @@ async function loadBotAnalysis() {
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
let auditOffset = 0;
let auditOffset = 0;
let _modlogNext = null;
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) {
async function loadModLog(reset) {
const c = document.getElementById('modlog-content');
const c = document.getElementById('modlog-content');
const more = document.getElementById('modlog-more');
const more = document.getElementById('modlog-more');
- const service = document.getElementById('modlog-service').value;
if (reset) { _modlogNext = null; c.innerHTML = '<div class="loading">Loading...</div>'; }
if (reset) { _modlogNext = null; c.innerHTML = '<div class="loading">Loading...</div>'; }
try {
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));
if (!reset && _modlogNext) q.set('before', String(_modlogNext));
const data = await api(`/api/v1/staff/moderation-audit?${q}`);
const data = await api(`/api/v1/staff/moderation-audit?${q}`);
const items = data.items || [];
const items = data.items || [];
@@ -3,7 +3,8 @@
@@ -3,7 +3,8 @@
* The network's moderation audit log (ADR-022): every staff action any service reports as an event,
* 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.
* 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
* community.moderation.action staff actions on other people's content in Community
* tips.interaction.moderated a paid message filtered, held, hidden or restored
* tips.interaction.moderated a paid message filtered, held, hidden or restored
* billing.staff.action a staff money action in the Billing console
* billing.staff.action a staff money action in the Billing console
@@ -11,13 +12,14 @@
@@ -11,13 +12,14 @@
* Rows are written by the Events consumer (server/notifications/events-consumer.js) inside its inbox
* 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
* 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
* 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 express = require('express');
const { staff } = require('openvibe-contracts');
const { staff } = require('openvibe-contracts');
const { staffClaims } = require('../auth/staff-claims');
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 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 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; };
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) {
@@ -52,9 +54,10 @@ function rowOf(event) {
const actorId = actor.type === 'user' ? actor.id : null;
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() };
const base = { event_id: event.event_id, occurred_at: str(event.occurred_at || event.timestamp, 40) || new Date().toISOString() };
switch (event.event_type) {
switch (event.event_type) {
- case 'chat.moderation.action': {
+ case 'chat.moderation.action':
+ case 'live.moderation.action': {
const d = obj(p.details);
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),
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) };
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) {
@@ -90,15 +93,19 @@ function createModerationAudit(db) {
return ins.run(row).changes ? 'recorded' : 'ignored:duplicate';
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 where = [];
const args = [];
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 (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 (actor) { where.push('actor_subject = ?'); args.push(String(actor)); }
if (target) { where.push('(target_id = ? OR target_subject = ?)'); args.push(String(target), String(target)); }
if (target) { where.push('(target_id = ? OR target_subject = ?)'); args.push(String(target), String(target)); }
const b = parseInt(before, 10);
const b = parseInt(before, 10);
if (Number.isFinite(b) && b > 0) { where.push('id < ?'); args.push(b); }
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 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 more = rows.length > n;
const items = rows.slice(0, n).map((r) => ({ ...r, details: (() => { try { return JSON.parse(r.details); } catch { return {}; } })() }));
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) {
@@ -111,7 +118,13 @@ function createModerationAudit(db) {
r.get('/', (req, res) => {
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' });
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.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;
return r;
}
}
@@ -119,4 +132,16 @@ function createModerationAudit(db) {
@@ -119,4 +132,16 @@ function createModerationAudit(db) {
return { record, list, router, TOPICS };
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 };
@@ -58,7 +58,7 @@ const rows = (userId) => db.prepare('SELECT * FROM notifications WHERE user_id =
@@ -58,7 +58,7 @@ const rows = (userId) => db.prepare('SELECT * FROM notifications WHERE user_id =
(async () => {
(async () => {
// The subscriptions this consumer is meant for, and nothing else.
// 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([]), TOPICS, 'scripts/subscribe-events.js subscribes every topic');
assert.deepStrictEqual(topicsFrom(['--topic', 'live.stream.started']), ['live.stream.started']);
assert.deepStrictEqual(topicsFrom(['--topic', 'live.stream.started']), ['live.stream.started']);
assert.throws(() => topicsFrom(['--topic', 'live.*']));
assert.throws(() => topicsFrom(['--topic', 'live.*']));
@@ -28,22 +28,32 @@ const ev = (type, payload, actor = { type: 'user', id: MOD }) => ({ event_id: id
@@ -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 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 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 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 } });
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(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');
assert.strictEqual(db.prepare('SELECT COUNT(*) AS n FROM notifications').get().n, 0, 'audit events notify nobody');
const all = audit.list({});
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]));
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.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.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.tips.action, 'interaction.hidden');
assert.strictEqual(byService.billing.actor_subject, MOD);
assert.strictEqual(byService.billing.actor_subject, MOD);
assert.strictEqual(audit.list({ service: 'community' }).items.length, 1);
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 });
const page = audit.list({ limit: 2 });
assert.strictEqual(page.items.length, 2);
assert.strictEqual(page.items.length, 2);
assert.strictEqual(audit.list({ limit: 2, before: page.next }).items.length, 2, 'paged by id');
assert.strictEqual(audit.list({ limit: 2, before: page.next }).items.length, 2, 'paged by id');
@@ -70,7 +70,7 @@ const rel = (contractsVersion, packages) => [200, { service: 'x', release: 'abcd
@@ -70,7 +70,7 @@ const rel = (contractsVersion, packages) => [200, { service: 'x', release: 'abcd
let r = await get('/api/v1/registry/topics');
let r = await get('/api/v1/registry/topics');
assert.ok(r.body.topics.length >= produced.size, 'no longer empty');
assert.ok(r.body.topics.length >= produced.size, 'no longer empty');
r = await get('/api/v1/registry/topics?producer=live');
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');
r = await get('/api/v1/registry/topics?consumer=network');
assert.deepStrictEqual(r.body.topics.map(t => t.topic).sort(), [...TOPICS].sort());
assert.deepStrictEqual(r.body.topics.map(t => t.topic).sort(), [...TOPICS].sort());
r = await get('/api/v1/registry/topics?prefix=media.vod.');
r = await get('/api/v1/registry/topics?prefix=media.vod.');