openvibers/openvibe.network Commits

Moderation audit log (WS-D task 1): records tools, games, wiki, blog, news, reviews, deals, coupons, trade and codes .moderation.action (common.moderation-action@1, Contracts 0.53.0), one row each with the service from the event's prefix (action, actor_subject, target type/id/owner, reason, details); the consumer's TOPICS (and so scripts/subscribe-events.js) cover them; the admin Moderation log filters by each. Pins: openvibe-contracts 0.49.0 → 0.53.0

OP OpenVibers committed 26 Sept 2026 · 89f3eee462 · 6 files, +57 −12


Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex 35b248f2..4e0a36c7 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.49.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.53.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.13.0",
"uuid": "^11.1.0",
@@@ -1465,9 +1465,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.49.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",
-- "integrity": "sha512-lapsLoBm3ZpndgiOMELdCBh2PJtuuyqj6SUhQsfwJkxnLdQfyhXfRFgiuLh+iQrg4kqtG9611191Mp8zAfk7fQ==",
++ "version": "0.53.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.53.0",
++ "integrity": "sha512-U+/QMB9dYHF+1ZS7ZZf+aYDokI8nuZmMZh1qOvisT7iB+QQ4uxr03E3wHm1/qOr+IHlt+7BzFaOPVw6K6cOSxA==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex 67c08f1b..104a881a 100644
---- a/package.json
++++ b/package.json
@@@ -23,7 +23,7 @@
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.53.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.13.0",
"uuid": "^11.1.0",
ddiff --git a/public/admin.html b/public/admin.html
iindex 74906419..fad84e17 100644
---- a/public/admin.html
++++ b/public/admin.html
@@@ -390,10 +390,10 @@ 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, Live, Community, Tips and Billing. Each service enforces its own moderation; this is the record.</p>
++ <p class="muted" style="margin:0 0 10px">Staff actions reported by Chat, Live, Community, Tips, Billing and the other services (Tools, Games, Wiki, Blog, News, Reviews, Deals, Coupons, Trade, Codes). 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>
++ <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><option value="blog">Blog</option><option value="codes">Codes</option><option value="coupons">Coupons</option><option value="deals">Deals</option><option value="games">Games</option><option value="news">News</option><option value="reviews">Reviews</option><option value="tools">Tools</option><option value="trade">Trade</option><option value="wiki">Wiki</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>
ddiff --git a/server/admin/moderation-audit.js b/server/admin/moderation-audit.js
iindex 7561c775..9a280091 100644
---- a/server/admin/moderation-audit.js
++++ b/server/admin/moderation-audit.js
@@@ -8,6 +8,10 @@
* 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
++ * <service>.moderation.action common.moderation-action@1 (Contracts 0.53.0) from tools, games, wiki,
++ * blog, news, reviews, deals, coupons, trade and codes: a staff or
++ * moderator action on someone else's content there; the service is the
++ * event's prefix
*
* 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
@@@ -19,7 +23,10 @@ const express = require('express');
const { staff } = require('openvibe-contracts');
const { staffClaims } = require('../auth/staff-claims');
--const TOPICS = Object.freeze(['chat.moderation.action', 'live.moderation.action', 'community.moderation.action', 'tips.interaction.moderated', 'billing.staff.action']);
++// Producers whose event is common.moderation-action@1 (action, target { type, id, owner_subject }, actor_subject, reason, details).
++const COMMON_SERVICES = Object.freeze(['tools', 'games', 'wiki', 'blog', 'news', 'reviews', 'deals', 'coupons', 'trade', 'codes']);
++const TOPICS = Object.freeze(['chat.moderation.action', 'live.moderation.action', 'community.moderation.action', 'tips.interaction.moderated', 'billing.staff.action',
++ ...COMMON_SERVICES.map((svc) => `${svc}.moderation.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; };
@@@ -72,6 +79,20 @@ function rowOf(event) {
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 'tools.moderation.action':
++ case 'games.moderation.action':
++ case 'wiki.moderation.action':
++ case 'blog.moderation.action':
++ case 'news.moderation.action':
++ case 'reviews.moderation.action':
++ case 'deals.moderation.action':
++ case 'coupons.moderation.action':
++ case 'trade.moderation.action':
++ case 'codes.moderation.action': {
++ const t = obj(p.target);
++ return { ...base, service: event.event_type.split('.')[0], action: str(p.action, 64) || 'unknown', actor_subject: str(p.actor_subject || actorId, 64),
++ target_type: str(t.type, 40), 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 'billing.staff.action': {
const t = obj(p.target);
return { ...base, service: 'billing', action: str(p.action, 64) || 'unknown', actor_subject: str(actorId, 64),
ddiff --git a/test/events-consumer.test.js b/test/events-consumer.test.js
iindex 0ca3a060..bee66ab2 100644
---- a/test/events-consumer.test.js
++++ b/test/events-consumer.test.js
@@@ -58,7 +58,8 @@ 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', 'live.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',
++ ...['tools', 'games', 'wiki', 'blog', 'news', 'reviews', 'deals', 'coupons', 'trade', 'codes'].map((svc) => `${svc}.moderation.action`)]);
assert.deepStrictEqual(topicsFrom([]), TOPICS, 'scripts/subscribe-events.js subscribes every topic');
assert.deepStrictEqual(topicsFrom(['--topic', 'live.stream.started']), ['live.stream.started']);
assert.throws(() => topicsFrom(['--topic', 'live.*']));
ddiff --git a/test/moderation-audit.test.js b/test/moderation-audit.test.js
iindex 55792951..396ba438 100644
---- a/test/moderation-audit.test.js
++++ b/test/moderation-audit.test.js
@@@ -1,7 +1,7 @@
'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.
++// The moderation audit log (ADR-022): staff actions from Chat/Live, Community, Tips, Billing and the ten
++// services on common.moderation-action@1 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');
@@@ -59,6 +59,29 @@ const ev = (type, payload, actor = { type: 'user', id: MOD }) => ({ event_id: id
assert.strictEqual(audit.list({ limit: 2, before: page.next }).items.length, 2, 'paged by id');
assert.strictEqual(rowOf(ev('chat.message.deleted', {})), null);
++ // The ten services on common.moderation-action@1 (Contracts 0.53.0): subscribed, one row each, the service from the prefix.
++ const contracts = require('openvibe-contracts');
++ const { TOPICS } = require('../server/notifications/events-consumer');
++ const COMMON = ['tools', 'games', 'wiki', 'blog', 'news', 'reviews', 'deals', 'coupons', 'trade', 'codes'];
++ for (const svc of COMMON) {
++ const type = `${svc}.moderation.action`;
++ assert.ok(TOPICS.includes(type), `${type} is one of the consumer's subscriptions`);
++ const payload = { action: 'item.hidden', target: { type: `${svc}_item`, id: `${svc}-1`, owner_subject: TARGET }, actor_subject: MOD, reason: `${svc} reason`, details: { previous: 'visible' } };
++ assert.ok(contracts.validate(`${type}@1`, payload).valid, `${type}: a valid payload`);
++ assert.strictEqual(consumer.apply(ev(type, payload)).outcome, 'recorded', type);
++ }
++ for (const svc of COMMON) {
++ const rows = audit.list({ service: svc }).items;
++ assert.strictEqual(rows.length, 1, svc);
++ const r = rows[0];
++ assert.deepStrictEqual([r.action, r.actor_subject, r.target_type, r.target_id, r.target_subject, r.scope, r.reason, r.details],
++ ['item.hidden', MOD, `${svc}_item`, `${svc}-1`, TARGET, null, `${svc} reason`, { previous: 'visible' }], svc);
++ }
++ assert.strictEqual(audit.list({ action: 'item.hidden' }).items.length, COMMON.length);
++ // A service acting without a person names no actor; an unknown owner is no target subject.
++ const bySvc = rowOf(ev('coupons.moderation.action', { action: 'merchant.disabled', target: { type: 'merchant', id: 'mer_1', owner_subject: null }, actor_subject: null, details: {} }, { type: 'service', id: 'coupons' }));
++ assert.deepStrictEqual([bySvc.service, bySvc.action, bySvc.actor_subject, bySvc.target_subject, bySvc.reason], ['coupons', 'merchant.disabled', null, null, 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(); });