openvibers/openvibe.network Commits
The moderation audit log (ADR-022): Network records chat.moderation.action (Chat and Live), community.moderation.action, tips.interaction.moderated and billing.staff.action from Events, once each inside the consumer's inbox transaction and never as notifications; staff with staff.moderation.logs list them at GET /api/v1/staff/moderation-audit (service/actor/target filters, paged) and in admin's Moderation log tab; openvibe-contracts 0.36.0
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.35.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.36.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.5.0","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.35.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.35.0",-- "integrity": "sha512-5jkH9Gt9MVbYaxo/Fi1VSH0RwdPkbary7SOm2m9e5EvXYu8L/cJHMm6KrMKxa7MV6TJCATDeRpGLDOCaUeZmSw==",++ "version": "0.36.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.36.0",++ "integrity": "sha512-dDl0IDlVngVksOJZAisLpUJ32RIZJHy6MfpxkNfi/m4AHi86TWLkGcj3b1BdiyL7DjtNm6aX6PfA3ff1O9Dbdg==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -6,7 +6,7 @@"scripts": {"start": "node server/index.js","dev": "NODE_ENV=development node server/index.js",-- "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/frame.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/modules.test.js && node test/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-topics.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/events-consumer.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js && node test/unknown-paths.test.js && node test/oidc-discovery.test.js && node test/refresh-tokens.test.js && node test/provider-secrets.test.js && node test/first-party-origins.test.js && node test/updates.test.js && node test/static-headers.test.js && node test/library-tags.test.js",++ "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/frame.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/modules.test.js && node test/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-topics.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/events-consumer.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js && node test/unknown-paths.test.js && node test/oidc-discovery.test.js && node test/refresh-tokens.test.js && node test/provider-secrets.test.js && node test/first-party-origins.test.js && node test/updates.test.js && node test/static-headers.test.js && node test/library-tags.test.js && node test/moderation-audit.test.js","reset-db": "node server/reset-db.js","grant-admin": "node server/grant-admin.js"},@@@ -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.35.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.36.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.5.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0","uuid": "^11.1.0",@@@ -212,6 +212,7 @@ body{min-height:100%;color:var(--text);font-family:'Inter',-apple-system,BlinkMa<button class="admin-tab-btn owner-only" onclick="showTab('email',this)"><i class="fa-solid fa-envelope"></i> Email</button><button class="admin-tab-btn" onclick="showTab('vkeys',this)"><i class="fa-solid fa-key"></i> Keys</button><button class="admin-tab-btn" onclick="showTab('audit',this)"><i class="fa-solid fa-clipboard-list"></i> Audit</button>++ <button class="admin-tab-btn" onclick="showTab('modlog',this)"><i class="fa-solid fa-gavel"></i> Moderation log</button><button class="admin-tab-btn" onclick="showTab('analytics',this)"><i class="fa-solid fa-chart-pie"></i> Analytics</button><button class="admin-tab-btn owner-only" onclick="showTab('domains',this)"><i class="fa-solid fa-globe"></i> Domains</button><button class="admin-tab-btn owner-only" onclick="showTab('deploy',this)"><i class="fa-solid fa-server"></i> Deploy</button>@@@ -383,6 +384,17 @@ body{min-height:100%;color:var(--text);font-family:'Inter',-apple-system,BlinkMa<div id="audit-pager" style="display:flex;gap:8px;align-items:center;justify-content:center;margin-top:12px"></div></div>++ <!-- 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>++ <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>++<!-- Analytics --><div class="section" id="sec-analytics"><div class="sub-tabs" id="analytics-sub-tabs">@@@ -798,6 +810,7 @@ const tabLoaders = {email: loadEmail,vkeys: loadVKeys,audit: loadAudit,++ modlog: () => loadModLog(true),analytics: loadAnalytics,deploy: loadDeployOverview,domains: loadDomains,@@@ -3516,6 +3529,39 @@ async function loadBotAnalysis() {// Audit Log// ═══════════════════════════════════════════════════════════════let auditOffset = 0;++let _modlogNext = null;++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);++ if (!reset && _modlogNext) q.set('before', String(_modlogNext));++ const data = await api(`/api/v1/staff/moderation-audit?${q}`);++ const items = data.items || [];++ const rowsHtml = items.map(e => `++ <tr>++ <td style="white-space:nowrap;font-size:11px" title="${esc(e.occurred_at)}">${e.occurred_at ? timeAgo(e.occurred_at) : '-'}</td>++ <td><span class="badge badge-info">${esc(e.service)}</span></td>++ <td><strong>${esc(e.action)}</strong>${e.scope ? `<div class="muted" style="font-size:11px">${esc(e.scope)}</div>` : ''}</td>++ <td style="font-size:11px"><code>${esc(e.actor_subject || '-')}</code></td>++ <td style="font-size:11px">${esc(e.target_type || '')} <code>${esc(e.target_id || '-')}</code>${e.target_subject && e.target_subject !== e.target_id ? `<div class="muted">${esc(e.target_subject)}</div>` : ''}</td>++ <td style="font-size:11px;max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(e.reason || '')}">${esc(e.reason || '-')}</td>++ </tr>`).join('');++ if (reset) {++ c.innerHTML = items.length ? `<table class="admin-table"><thead><tr><th>When</th><th>Service</th><th>Action</th><th>By</th><th>Target</th><th>Reason</th></tr></thead><tbody id="modlog-rows">${rowsHtml}</tbody></table>` : '<p class="muted">No staff actions recorded yet.</p>';++ } else {++ document.getElementById('modlog-rows')?.insertAdjacentHTML('beforeend', rowsHtml);++ }++ _modlogNext = data.next || null;++ more.hidden = !_modlogNext;++ } catch (err) {++ c.innerHTML = `<p class="muted">The moderation log could not be loaded: ${esc(err.message || 'error')}</p>`;++ }++}++async function loadAudit() {const c = document.getElementById('audit-content');const pager = document.getElementById('audit-pager');nnew file mode 100644@@@ -0,0 +1,122 @@++'use strict';++/**++ * 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)++ * 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++ *++ * 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).++ */++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 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; };++++function ensure(db) {++ db.exec(`++ CREATE TABLE IF NOT EXISTS moderation_audit (++ id INTEGER PRIMARY KEY AUTOINCREMENT,++ event_id TEXT NOT NULL UNIQUE,++ service TEXT NOT NULL,++ action TEXT NOT NULL,++ actor_subject TEXT,++ target_type TEXT,++ target_id TEXT,++ target_subject TEXT,++ scope TEXT,++ reason TEXT,++ details TEXT NOT NULL DEFAULT '{}',++ occurred_at TEXT NOT NULL,++ recorded_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))++ );++ CREATE INDEX IF NOT EXISTS idx_modaudit_when ON moderation_audit(occurred_at);++ CREATE INDEX IF NOT EXISTS idx_modaudit_service ON moderation_audit(service, id);++ CREATE INDEX IF NOT EXISTS idx_modaudit_actor ON moderation_audit(actor_subject, id);++ `);++}++++/** One envelope → one audit row (or null when it is not an audit event). No I/O. */++function rowOf(event) {++ const p = obj(event && event.payload);++ const actor = obj(event && event.actor);++ 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': {++ const d = obj(p.details);++ return { ...base, service: '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) };++ }++ case 'community.moderation.action': {++ const t = obj(p.target);++ return { ...base, service: 'community', action: str(p.action, 64) || 'unknown', actor_subject: str(p.actor_subject || actorId, 64),++ target_type: str(t.type, 32), target_id: str(t.id, 200), target_subject: str(t.owner_subject, 64), scope: null, reason: str(p.reason, 500), details: json(p.details) };++ }++ case 'tips.interaction.moderated': {++ const c = obj(p.creator);++ return { ...base, service: 'tips', action: `interaction.${str(p.action, 32) || 'unknown'}`, actor_subject: null,++ target_type: 'interaction', target_id: str(p.interaction_id, 64), target_subject: str(c.id, 64), scope: str(`by:${p.by || 'unknown'}`, 64), reason: null,++ details: json({ moderation_state: p.moderation_state, cancelled_effects: p.cancelled_effects }) };++ }++ case 'billing.staff.action': {++ const t = obj(p.target);++ return { ...base, service: 'billing', action: str(p.action, 64) || 'unknown', actor_subject: str(actorId, 64),++ target_type: str(t.type, 32), target_id: str(t.id, 200), target_subject: null, scope: null, reason: str(p.reason, 500), details: json(p.detail) };++ }++ default: return null;++ }++}++++function createModerationAudit(db) {++ ensure(db);++ const ins = db.prepare(`INSERT OR IGNORE INTO moderation_audit (event_id, service, action, actor_subject, target_type, target_id, target_subject, scope, reason, details, occurred_at)++ VALUES (@event_id, @service, @action, @actor_subject, @target_type, @target_id, @target_subject, @scope, @reason, @details, @occurred_at)`);++++ /** Inside the consumer's inbox transaction: 'recorded' or an 'ignored:*' outcome. */++ function record(event) {++ const row = rowOf(event);++ if (!row) return 'ignored:type';++ return ins.run(row).changes ? 'recorded' : 'ignored:duplicate';++ }++++ function list({ service = null, actor = null, target = null, before = null, limit = 50 } = {}) {++ const where = [];++ const args = [];++ if (service) { where.push('service = ?'); args.push(String(service)); }++ 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 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 {}; } })() }));++ return { items, next: more && items.length ? items[items.length - 1].id : null };++ }++++ /** Express router for GET /api/v1/staff/moderation-audit (mount behind requireAuth). */++ function router() {++ const r = express.Router();++ 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 }));++ });++ return r;++ }++++ return { record, list, router, TOPICS };++}++++module.exports = { createModerationAudit, rowOf, TOPICS };@@@ -364,9 +364,11 @@ app.locals.emailService = emailService;const liveFollowers = privateKey.includes('BEGIN')? require('./notifications/live-followers').createLiveFollowers({ privateKey, issuer: config.jwt.issuer, liveUrl: config.services.live.internalUrl }): null;++// The moderation audit log (ADR-022): staff actions every service reports, readable by staff.++const moderationAudit = require('./admin/moderation-audit').createModerationAudit(db);eventsConsumer = require('./notifications/events-consumer').createEventsConsumer({db, notifications: notificationService, secrets: config.eventsWebhookSecrets,-- liveFollowers, discord: () => app.locals.discordService || null,++ liveFollowers, discord: () => app.locals.discordService || null, moderationAudit,});console.log(`[Events consumer] ${eventsConsumer.enabled ? 'on' : 'off (NETWORK_EVENTS_SECRET unset)'}: POST /internal/events`);@@@ -380,6 +382,8 @@ const authRoutes = require('./auth/routes');const jwt = require('jsonwebtoken');// Sliding session guard (server/auth/session.js): renewable tokens are accepted and renewed.const requireAuth = require('./auth/session').makeRequireAuth(() => ({ db, publicKey, config }), authRoutes.signToken);++// The moderation audit log for staff (staff.moderation.logs): server/admin/moderation-audit.js.++app.use('/api/v1/staff/moderation-audit', requireAuth, moderationAudit.router());// ── Routes ───────────────────────────────────────────────────// Public key endpoint (services fetch this to verify JWTs).@@@ -49,7 +49,8 @@ const { LiveFollowersError } = require('./live-followers');const CONSUMER = 'network-notifications';const INBOX_TABLE = 'network_event_inbox';--const TOPICS = Object.freeze(['deals.watch.matched', 'trade.alert.triggered', 'live.stream.started']);++const AUDIT_TOPICS = require('../admin/moderation-audit').TOPICS;++const TOPICS = Object.freeze(['deals.watch.matched', 'trade.alert.triggered', 'live.stream.started', ...AUDIT_TOPICS]);const LIVE_STARTED_MAX_AGE_MS = 30 * 60 * 1000;const EVENT_ID_RE = /^evt_[0-9A-HJKMNP-TV-Z]{26}$/;@@@ -155,7 +156,7 @@ function liveStarted(event, { now, maxAgeMs }) {* @param {{ forStream(id: number): Promise<object> }} [o.liveFollowers] ./live-followers.js* @param {() => ({ sendLiveAlert(streamer: object, stream: object): Promise<object> }|null)} [o.discord]*/--function createEventsConsumer({ db, notifications, secrets, liveFollowers = null, discord = () => null, liveStartedMaxAgeMs = LIVE_STARTED_MAX_AGE_MS, now = () => Date.now(), log = console }) {++function createEventsConsumer({ db, notifications, secrets, liveFollowers = null, discord = () => null, moderationAudit = null, liveStartedMaxAgeMs = LIVE_STARTED_MAX_AGE_MS, now = () => Date.now(), log = console }) {const keys = Array.isArray(secrets) ? secrets.filter((s) => typeof s === 'string' && s.length >= 32) : secretsFrom(secrets);const inbox = createInbox(db, { table: INBOX_TABLE, now });inbox.ensureSchema();@@@ -219,6 +220,8 @@ function createEventsConsumer({ db, notifications, secrets, liveFollowers = nullfunction apply(event, prep) {let after = null;const r = inbox.once(CONSUMER, event.event_id, () => {++ // Staff actions go to the moderation audit log (ADR-022), never to anyone's inbox.++ if (AUDIT_TOPICS.includes(event.event_type)) return moderationAudit ? moderationAudit.record(event) : 'ignored:audit_off';if (event.event_type === 'live.stream.started') {const out = liveStreamStarted(event, prep);if (typeof out === 'string') return out;@@@ -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']);++ 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(topicsFrom([]), TOPICS, 'scripts/subscribe-events.js subscribes every topic');assert.deepStrictEqual(topicsFrom(['--topic', 'live.stream.started']), ['live.stream.started']);assert.throws(() => topicsFrom(['--topic', 'live.*']));nnew file mode 100644@@@ -0,0 +1,68 @@++'use strict';++// The moderation audit log (ADR-022): staff actions from Chat/Live, Community, Tips and Billing arrive as++// events, are recorded once (inside the consumer's inbox transaction), and staff with++// staff.moderation.logs can list them; nobody else can.++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++const express = require('express');++const { ids } = require('openvibe-contracts');++const { initDb } = require('../server/db/database');++const { NotificationService } = require('../server/notifications/notification-service');++const { createEventsConsumer } = require('../server/notifications/events-consumer');++const { createModerationAudit, rowOf } = require('../server/admin/moderation-audit');++++const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-modaudit-'));++const log = console.log; console.log = () => {};++const db = initDb(path.join(dir, 'network.db'));++console.log = log;++const MOD = ids.newId('user'), TARGET = ids.newId('user');++const ev = (type, payload, actor = { type: 'user', id: MOD }) => ({ event_id: ids.newId('event'), event_type: type, version: 1, source: type.split('.')[0], actor, occurred_at: new Date().toISOString(), payload });++++(async () => {++ const audit = createModerationAudit(db);++ const consumer = createEventsConsumer({ db, notifications: new NotificationService(db), secrets: 'a'.repeat(40), moderationAudit: audit });++++ 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 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);++ 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);++ 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);++ 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');++ assert.strictEqual(rowOf(ev('chat.message.deleted', {})), null);++++ // Only staff with staff.moderation.logs read it.++ const app = express();++ app.use((req, _res, next) => { req.user = req.headers['x-role'] ? { id: 1, role: req.headers['x-role'] } : null; next(); });++ app.use('/api/v1/staff/moderation-audit', audit.router());++ const srv = http.createServer(app);++ await new Promise((r) => srv.listen(0, '127.0.0.1', r));++ const base = `http://127.0.0.1:${srv.address().port}/api/v1/staff/moderation-audit`;++ try {++ assert.strictEqual((await fetch(base, { headers: { 'x-role': 'user' } })).status, 403);++ assert.strictEqual((await fetch(base, { headers: { 'x-role': 'streamer' } })).status, 403);++ const r = await fetch(`${base}?service=chat`, { headers: { 'x-role': 'global_mod' } });++ assert.strictEqual(r.status, 200);++ assert.strictEqual((await r.json()).items[0].action, 'ban');++ assert.strictEqual((await fetch(base, { headers: { 'x-role': 'admin' } })).status, 200);++ } finally { srv.close(); }++ console.log('moderation audit: all checks passed');++})().catch((e) => { console.error(e); process.exit(1); });
@@ -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.35.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.36.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.5.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.5.0",
"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.35.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.35.0",
- "integrity": "sha512-5jkH9Gt9MVbYaxo/Fi1VSH0RwdPkbary7SOm2m9e5EvXYu8L/cJHMm6KrMKxa7MV6TJCATDeRpGLDOCaUeZmSw==",
+ "version": "0.36.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.36.0",
+ "integrity": "sha512-dDl0IDlVngVksOJZAisLpUJ32RIZJHy6MfpxkNfi/m4AHi86TWLkGcj3b1BdiyL7DjtNm6aX6PfA3ff1O9Dbdg==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
"scripts": {
"scripts": {
"start": "node server/index.js",
"start": "node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
- "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/frame.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/modules.test.js && node test/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-topics.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/events-consumer.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js && node test/unknown-paths.test.js && node test/oidc-discovery.test.js && node test/refresh-tokens.test.js && node test/provider-secrets.test.js && node test/first-party-origins.test.js && node test/updates.test.js && node test/static-headers.test.js && node test/library-tags.test.js",
+ "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/frame.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/modules.test.js && node test/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-topics.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/events-consumer.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js && node test/unknown-paths.test.js && node test/oidc-discovery.test.js && node test/refresh-tokens.test.js && node test/provider-secrets.test.js && node test/first-party-origins.test.js && node test/updates.test.js && node test/static-headers.test.js && node test/library-tags.test.js && node test/moderation-audit.test.js",
"reset-db": "node server/reset-db.js",
"reset-db": "node server/reset-db.js",
"grant-admin": "node server/grant-admin.js"
"grant-admin": "node server/grant-admin.js"
},
},
@@ -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.35.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.36.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.5.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.5.0",
"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",
@@ -212,6 +212,7 @@ body{min-height:100%;color:var(--text);font-family:'Inter',-apple-system,BlinkMa
@@ -212,6 +212,7 @@ body{min-height:100%;color:var(--text);font-family:'Inter',-apple-system,BlinkMa
<button class="admin-tab-btn owner-only" onclick="showTab('email',this)"><i class="fa-solid fa-envelope"></i> Email</button>
<button class="admin-tab-btn owner-only" onclick="showTab('email',this)"><i class="fa-solid fa-envelope"></i> Email</button>
<button class="admin-tab-btn" onclick="showTab('vkeys',this)"><i class="fa-solid fa-key"></i> Keys</button>
<button class="admin-tab-btn" onclick="showTab('vkeys',this)"><i class="fa-solid fa-key"></i> Keys</button>
<button class="admin-tab-btn" onclick="showTab('audit',this)"><i class="fa-solid fa-clipboard-list"></i> Audit</button>
<button class="admin-tab-btn" onclick="showTab('audit',this)"><i class="fa-solid fa-clipboard-list"></i> Audit</button>
+ <button class="admin-tab-btn" onclick="showTab('modlog',this)"><i class="fa-solid fa-gavel"></i> Moderation log</button>
<button class="admin-tab-btn" onclick="showTab('analytics',this)"><i class="fa-solid fa-chart-pie"></i> Analytics</button>
<button class="admin-tab-btn" onclick="showTab('analytics',this)"><i class="fa-solid fa-chart-pie"></i> Analytics</button>
<button class="admin-tab-btn owner-only" onclick="showTab('domains',this)"><i class="fa-solid fa-globe"></i> Domains</button>
<button class="admin-tab-btn owner-only" onclick="showTab('domains',this)"><i class="fa-solid fa-globe"></i> Domains</button>
<button class="admin-tab-btn owner-only" onclick="showTab('deploy',this)"><i class="fa-solid fa-server"></i> Deploy</button>
<button class="admin-tab-btn owner-only" onclick="showTab('deploy',this)"><i class="fa-solid fa-server"></i> Deploy</button>
@@ -383,6 +384,17 @@ body{min-height:100%;color:var(--text);font-family:'Inter',-apple-system,BlinkMa
@@ -383,6 +384,17 @@ body{min-height:100%;color:var(--text);font-family:'Inter',-apple-system,BlinkMa
<div id="audit-pager" style="display:flex;gap:8px;align-items:center;justify-content:center;margin-top:12px"></div>
<div id="audit-pager" style="display:flex;gap:8px;align-items:center;justify-content:center;margin-top:12px"></div>
</div>
</div>
+ <!-- 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>
+ <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>
+
<!-- Analytics -->
<!-- Analytics -->
<div class="section" id="sec-analytics">
<div class="section" id="sec-analytics">
<div class="sub-tabs" id="analytics-sub-tabs">
<div class="sub-tabs" id="analytics-sub-tabs">
@@ -798,6 +810,7 @@ const tabLoaders = {
@@ -798,6 +810,7 @@ const tabLoaders = {
email: loadEmail,
email: loadEmail,
vkeys: loadVKeys,
vkeys: loadVKeys,
audit: loadAudit,
audit: loadAudit,
+ modlog: () => loadModLog(true),
analytics: loadAnalytics,
analytics: loadAnalytics,
deploy: loadDeployOverview,
deploy: loadDeployOverview,
domains: loadDomains,
domains: loadDomains,
@@ -3516,6 +3529,39 @@ async function loadBotAnalysis() {
@@ -3516,6 +3529,39 @@ async function loadBotAnalysis() {
// Audit Log
// Audit Log
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
let auditOffset = 0;
let auditOffset = 0;
+let _modlogNext = null;
+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);
+ if (!reset && _modlogNext) q.set('before', String(_modlogNext));
+ const data = await api(`/api/v1/staff/moderation-audit?${q}`);
+ const items = data.items || [];
+ const rowsHtml = items.map(e => `
+ <tr>
+ <td style="white-space:nowrap;font-size:11px" title="${esc(e.occurred_at)}">${e.occurred_at ? timeAgo(e.occurred_at) : '-'}</td>
+ <td><span class="badge badge-info">${esc(e.service)}</span></td>
+ <td><strong>${esc(e.action)}</strong>${e.scope ? `<div class="muted" style="font-size:11px">${esc(e.scope)}</div>` : ''}</td>
+ <td style="font-size:11px"><code>${esc(e.actor_subject || '-')}</code></td>
+ <td style="font-size:11px">${esc(e.target_type || '')} <code>${esc(e.target_id || '-')}</code>${e.target_subject && e.target_subject !== e.target_id ? `<div class="muted">${esc(e.target_subject)}</div>` : ''}</td>
+ <td style="font-size:11px;max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(e.reason || '')}">${esc(e.reason || '-')}</td>
+ </tr>`).join('');
+ if (reset) {
+ c.innerHTML = items.length ? `<table class="admin-table"><thead><tr><th>When</th><th>Service</th><th>Action</th><th>By</th><th>Target</th><th>Reason</th></tr></thead><tbody id="modlog-rows">${rowsHtml}</tbody></table>` : '<p class="muted">No staff actions recorded yet.</p>';
+ } else {
+ document.getElementById('modlog-rows')?.insertAdjacentHTML('beforeend', rowsHtml);
+ }
+ _modlogNext = data.next || null;
+ more.hidden = !_modlogNext;
+ } catch (err) {
+ c.innerHTML = `<p class="muted">The moderation log could not be loaded: ${esc(err.message || 'error')}</p>`;
+ }
+}
+
async function loadAudit() {
async function loadAudit() {
const c = document.getElementById('audit-content');
const c = document.getElementById('audit-content');
const pager = document.getElementById('audit-pager');
const pager = document.getElementById('audit-pager');
new file mode 100644
new file mode 100644
@@ -0,0 +1,122 @@
@@ -0,0 +1,122 @@
+'use strict';
+/**
+ * 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)
+ * 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
+ *
+ * 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).
+ */
+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 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; };
+
+function ensure(db) {
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS moderation_audit (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ event_id TEXT NOT NULL UNIQUE,
+ service TEXT NOT NULL,
+ action TEXT NOT NULL,
+ actor_subject TEXT,
+ target_type TEXT,
+ target_id TEXT,
+ target_subject TEXT,
+ scope TEXT,
+ reason TEXT,
+ details TEXT NOT NULL DEFAULT '{}',
+ occurred_at TEXT NOT NULL,
+ recorded_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
+ );
+ CREATE INDEX IF NOT EXISTS idx_modaudit_when ON moderation_audit(occurred_at);
+ CREATE INDEX IF NOT EXISTS idx_modaudit_service ON moderation_audit(service, id);
+ CREATE INDEX IF NOT EXISTS idx_modaudit_actor ON moderation_audit(actor_subject, id);
+ `);
+}
+
+/** One envelope → one audit row (or null when it is not an audit event). No I/O. */
+function rowOf(event) {
+ const p = obj(event && event.payload);
+ const actor = obj(event && event.actor);
+ 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': {
+ const d = obj(p.details);
+ return { ...base, service: '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) };
+ }
+ case 'community.moderation.action': {
+ const t = obj(p.target);
+ return { ...base, service: 'community', action: str(p.action, 64) || 'unknown', actor_subject: str(p.actor_subject || actorId, 64),
+ target_type: str(t.type, 32), target_id: str(t.id, 200), target_subject: str(t.owner_subject, 64), scope: null, reason: str(p.reason, 500), details: json(p.details) };
+ }
+ case 'tips.interaction.moderated': {
+ const c = obj(p.creator);
+ return { ...base, service: 'tips', action: `interaction.${str(p.action, 32) || 'unknown'}`, actor_subject: null,
+ target_type: 'interaction', target_id: str(p.interaction_id, 64), target_subject: str(c.id, 64), scope: str(`by:${p.by || 'unknown'}`, 64), reason: null,
+ details: json({ moderation_state: p.moderation_state, cancelled_effects: p.cancelled_effects }) };
+ }
+ case 'billing.staff.action': {
+ const t = obj(p.target);
+ return { ...base, service: 'billing', action: str(p.action, 64) || 'unknown', actor_subject: str(actorId, 64),
+ target_type: str(t.type, 32), target_id: str(t.id, 200), target_subject: null, scope: null, reason: str(p.reason, 500), details: json(p.detail) };
+ }
+ default: return null;
+ }
+}
+
+function createModerationAudit(db) {
+ ensure(db);
+ const ins = db.prepare(`INSERT OR IGNORE INTO moderation_audit (event_id, service, action, actor_subject, target_type, target_id, target_subject, scope, reason, details, occurred_at)
+ VALUES (@event_id, @service, @action, @actor_subject, @target_type, @target_id, @target_subject, @scope, @reason, @details, @occurred_at)`);
+
+ /** Inside the consumer's inbox transaction: 'recorded' or an 'ignored:*' outcome. */
+ function record(event) {
+ const row = rowOf(event);
+ if (!row) return 'ignored:type';
+ return ins.run(row).changes ? 'recorded' : 'ignored:duplicate';
+ }
+
+ function list({ service = null, actor = null, target = null, before = null, limit = 50 } = {}) {
+ const where = [];
+ const args = [];
+ if (service) { where.push('service = ?'); args.push(String(service)); }
+ 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 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 {}; } })() }));
+ return { items, next: more && items.length ? items[items.length - 1].id : null };
+ }
+
+ /** Express router for GET /api/v1/staff/moderation-audit (mount behind requireAuth). */
+ function router() {
+ const r = express.Router();
+ 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 }));
+ });
+ return r;
+ }
+
+ return { record, list, router, TOPICS };
+}
+
+module.exports = { createModerationAudit, rowOf, TOPICS };
@@ -364,9 +364,11 @@ app.locals.emailService = emailService;
@@ -364,9 +364,11 @@ app.locals.emailService = emailService;
const liveFollowers = privateKey.includes('BEGIN')
const liveFollowers = privateKey.includes('BEGIN')
? require('./notifications/live-followers').createLiveFollowers({ privateKey, issuer: config.jwt.issuer, liveUrl: config.services.live.internalUrl })
? require('./notifications/live-followers').createLiveFollowers({ privateKey, issuer: config.jwt.issuer, liveUrl: config.services.live.internalUrl })
: null;
: null;
+// The moderation audit log (ADR-022): staff actions every service reports, readable by staff.
+const moderationAudit = require('./admin/moderation-audit').createModerationAudit(db);
eventsConsumer = require('./notifications/events-consumer').createEventsConsumer({
eventsConsumer = require('./notifications/events-consumer').createEventsConsumer({
db, notifications: notificationService, secrets: config.eventsWebhookSecrets,
db, notifications: notificationService, secrets: config.eventsWebhookSecrets,
- liveFollowers, discord: () => app.locals.discordService || null,
+ liveFollowers, discord: () => app.locals.discordService || null, moderationAudit,
});
});
console.log(`[Events consumer] ${eventsConsumer.enabled ? 'on' : 'off (NETWORK_EVENTS_SECRET unset)'}: POST /internal/events`);
console.log(`[Events consumer] ${eventsConsumer.enabled ? 'on' : 'off (NETWORK_EVENTS_SECRET unset)'}: POST /internal/events`);
@@ -380,6 +382,8 @@ const authRoutes = require('./auth/routes');
@@ -380,6 +382,8 @@ const authRoutes = require('./auth/routes');
const jwt = require('jsonwebtoken');
const jwt = require('jsonwebtoken');
// Sliding session guard (server/auth/session.js): renewable tokens are accepted and renewed.
// Sliding session guard (server/auth/session.js): renewable tokens are accepted and renewed.
const requireAuth = require('./auth/session').makeRequireAuth(() => ({ db, publicKey, config }), authRoutes.signToken);
const requireAuth = require('./auth/session').makeRequireAuth(() => ({ db, publicKey, config }), authRoutes.signToken);
+// The moderation audit log for staff (staff.moderation.logs): server/admin/moderation-audit.js.
+app.use('/api/v1/staff/moderation-audit', requireAuth, moderationAudit.router());
// ── Routes ───────────────────────────────────────────────────
// ── Routes ───────────────────────────────────────────────────
// Public key endpoint (services fetch this to verify JWTs).
// Public key endpoint (services fetch this to verify JWTs).
@@ -49,7 +49,8 @@ const { LiveFollowersError } = require('./live-followers');
@@ -49,7 +49,8 @@ const { LiveFollowersError } = require('./live-followers');
const CONSUMER = 'network-notifications';
const CONSUMER = 'network-notifications';
const INBOX_TABLE = 'network_event_inbox';
const INBOX_TABLE = 'network_event_inbox';
-const TOPICS = Object.freeze(['deals.watch.matched', 'trade.alert.triggered', 'live.stream.started']);
+const AUDIT_TOPICS = require('../admin/moderation-audit').TOPICS;
+const TOPICS = Object.freeze(['deals.watch.matched', 'trade.alert.triggered', 'live.stream.started', ...AUDIT_TOPICS]);
const LIVE_STARTED_MAX_AGE_MS = 30 * 60 * 1000;
const LIVE_STARTED_MAX_AGE_MS = 30 * 60 * 1000;
const EVENT_ID_RE = /^evt_[0-9A-HJKMNP-TV-Z]{26}$/;
const EVENT_ID_RE = /^evt_[0-9A-HJKMNP-TV-Z]{26}$/;
@@ -155,7 +156,7 @@ function liveStarted(event, { now, maxAgeMs }) {
@@ -155,7 +156,7 @@ function liveStarted(event, { now, maxAgeMs }) {
* @param {{ forStream(id: number): Promise<object> }} [o.liveFollowers] ./live-followers.js
* @param {{ forStream(id: number): Promise<object> }} [o.liveFollowers] ./live-followers.js
* @param {() => ({ sendLiveAlert(streamer: object, stream: object): Promise<object> }|null)} [o.discord]
* @param {() => ({ sendLiveAlert(streamer: object, stream: object): Promise<object> }|null)} [o.discord]
*/
*/
-function createEventsConsumer({ db, notifications, secrets, liveFollowers = null, discord = () => null, liveStartedMaxAgeMs = LIVE_STARTED_MAX_AGE_MS, now = () => Date.now(), log = console }) {
+function createEventsConsumer({ db, notifications, secrets, liveFollowers = null, discord = () => null, moderationAudit = null, liveStartedMaxAgeMs = LIVE_STARTED_MAX_AGE_MS, now = () => Date.now(), log = console }) {
const keys = Array.isArray(secrets) ? secrets.filter((s) => typeof s === 'string' && s.length >= 32) : secretsFrom(secrets);
const keys = Array.isArray(secrets) ? secrets.filter((s) => typeof s === 'string' && s.length >= 32) : secretsFrom(secrets);
const inbox = createInbox(db, { table: INBOX_TABLE, now });
const inbox = createInbox(db, { table: INBOX_TABLE, now });
inbox.ensureSchema();
inbox.ensureSchema();
@@ -219,6 +220,8 @@ function createEventsConsumer({ db, notifications, secrets, liveFollowers = null
@@ -219,6 +220,8 @@ function createEventsConsumer({ db, notifications, secrets, liveFollowers = null
function apply(event, prep) {
function apply(event, prep) {
let after = null;
let after = null;
const r = inbox.once(CONSUMER, event.event_id, () => {
const r = inbox.once(CONSUMER, event.event_id, () => {
+ // Staff actions go to the moderation audit log (ADR-022), never to anyone's inbox.
+ if (AUDIT_TOPICS.includes(event.event_type)) return moderationAudit ? moderationAudit.record(event) : 'ignored:audit_off';
if (event.event_type === 'live.stream.started') {
if (event.event_type === 'live.stream.started') {
const out = liveStreamStarted(event, prep);
const out = liveStreamStarted(event, prep);
if (typeof out === 'string') return out;
if (typeof out === 'string') return out;
@@ -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']);
+ 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(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.*']));
new file mode 100644
new file mode 100644
@@ -0,0 +1,68 @@
@@ -0,0 +1,68 @@
+'use strict';
+// The moderation audit log (ADR-022): staff actions from Chat/Live, Community, Tips and Billing arrive as
+// events, are recorded once (inside the consumer's inbox transaction), and staff with
+// staff.moderation.logs can list them; nobody else can.
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+const express = require('express');
+const { ids } = require('openvibe-contracts');
+const { initDb } = require('../server/db/database');
+const { NotificationService } = require('../server/notifications/notification-service');
+const { createEventsConsumer } = require('../server/notifications/events-consumer');
+const { createModerationAudit, rowOf } = require('../server/admin/moderation-audit');
+
+const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-modaudit-'));
+const log = console.log; console.log = () => {};
+const db = initDb(path.join(dir, 'network.db'));
+console.log = log;
+const MOD = ids.newId('user'), TARGET = ids.newId('user');
+const ev = (type, payload, actor = { type: 'user', id: MOD }) => ({ event_id: ids.newId('event'), event_type: type, version: 1, source: type.split('.')[0], actor, occurred_at: new Date().toISOString(), payload });
+
+(async () => {
+ const audit = createModerationAudit(db);
+ const consumer = createEventsConsumer({ db, notifications: new NotificationService(db), secrets: 'a'.repeat(40), moderationAudit: audit });
+
+ 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 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);
+ 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);
+ 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);
+ 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');
+ assert.strictEqual(rowOf(ev('chat.message.deleted', {})), null);
+
+ // Only staff with staff.moderation.logs read it.
+ const app = express();
+ app.use((req, _res, next) => { req.user = req.headers['x-role'] ? { id: 1, role: req.headers['x-role'] } : null; next(); });
+ app.use('/api/v1/staff/moderation-audit', audit.router());
+ const srv = http.createServer(app);
+ await new Promise((r) => srv.listen(0, '127.0.0.1', r));
+ const base = `http://127.0.0.1:${srv.address().port}/api/v1/staff/moderation-audit`;
+ try {
+ assert.strictEqual((await fetch(base, { headers: { 'x-role': 'user' } })).status, 403);
+ assert.strictEqual((await fetch(base, { headers: { 'x-role': 'streamer' } })).status, 403);
+ const r = await fetch(`${base}?service=chat`, { headers: { 'x-role': 'global_mod' } });
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual((await r.json()).items[0].action, 'ban');
+ assert.strictEqual((await fetch(base, { headers: { 'x-role': 'admin' } })).status, 200);
+ } finally { srv.close(); }
+ console.log('moderation audit: all checks passed');
+})().catch((e) => { console.error(e); process.exit(1); });