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

OP OpenVibers committed 24 Sept 2026 · b1b9f92ce1 · 8 files, +253 −10


Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex 72bec3bd..25831c75 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -20,7 +20,7 @@
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.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",
ddiff --git a/package.json b/package.json
iindex 80a2c079..bf247a6a 100644
---- a/package.json
++++ b/package.json
@@@ -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",
ddiff --git a/public/admin.html b/public/admin.html
iindex b3f4f74a..44f7242c 100644
---- a/public/admin.html
++++ b/public/admin.html
@@@ -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');
ddiff --git a/server/admin/moderation-audit.js b/server/admin/moderation-audit.js
nnew file mode 100644
iindex 00000000..f4ace00d
---- /dev/null
++++ b/server/admin/moderation-audit.js
@@@ -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 };
ddiff --git a/server/index.js b/server/index.js
iindex be9a3a36..cbe6513c 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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).
ddiff --git a/server/notifications/events-consumer.js b/server/notifications/events-consumer.js
iindex b924979e..88f053d0 100644
---- a/server/notifications/events-consumer.js
++++ b/server/notifications/events-consumer.js
@@@ -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 = null
function 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;
ddiff --git a/test/events-consumer.test.js b/test/events-consumer.test.js
iindex 8f3815e3..e518cb71 100644
---- a/test/events-consumer.test.js
++++ b/test/events-consumer.test.js
@@@ -58,7 +58,7 @@ const rows = (userId) => db.prepare('SELECT * FROM notifications WHERE user_id =
(async () => {
// The subscriptions this consumer is meant for, and nothing else.
-- assert.deepStrictEqual(TOPICS, ['deals.watch.matched', 'trade.alert.triggered', 'live.stream.started']);
++ 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.*']));
ddiff --git a/test/moderation-audit.test.js b/test/moderation-audit.test.js
nnew file mode 100644
iindex 00000000..a6036a00
---- /dev/null
++++ b/test/moderation-audit.test.js
@@@ -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); });