openvibers/openvibe.community Commits

Staff actions on someone else's content publish community.moderation.action for the network's moderation audit log (ADR-022): paste edit, visibility change, delete, censor, bulk and fork cleanup, comment and post removal, thread delete, lock and unlock — in the same transaction as the change, never the content; an owner acting on their own content is not audited; openvibe-contracts 0.36.0

OP OpenVibers committed 24 Sept 2026 · bd3a551a9b · 7 files, +91 −18


Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex 3ae6db8..49b758e 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -17,7 +17,7 @@
"helmet": "^8.1.0",
"highlight.js": "^11.11.1",
"multer": "^2.4.0",
-- "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.7.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0"
},
@@@ -1057,9 +1057,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 8cba840..eaf0040 100644
---- a/package.json
++++ b/package.json
@@@ -26,7 +26,7 @@
"helmet": "^8.1.0",
"highlight.js": "^11.11.1",
"multer": "^2.4.0",
-- "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.7.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0"
},
ddiff --git a/server/events.js b/server/events.js
iindex edde218..ce2937e 100644
---- a/server/events.js
++++ b/server/events.js
@@@ -106,10 +106,22 @@ function commentCreated(comment, cthread) {
}, { isPublic: visibility !== 'hidden', actor: actorOf(comment.author_subject) });
}
++/**
++ * A staff action on someone else's content: community.moderation.action, for the network's
++ * moderation audit log (ADR-022). Call inside the same transaction as the change. Never the content.
++ */
++function moderationAction(v, action, target, { reason = null, details = {} } = {}) {
++ const actorSubject = v && v.subject ? v.subject : null;
++ const t = { type: target.type, id: String(target.id).slice(0, 200), ...(target.owner_subject !== undefined ? { owner_subject: target.owner_subject || null } : {}) };
++ return record('community.moderation.action', { type: 'moderation_action', id: `${t.type}:${t.id}`.slice(0, 200) },
++ { action, target: t, actor_subject: actorSubject, reason: reason ? String(reason).slice(0, 500) : null, details: details || {} },
++ { actor: actorOf(actorSubject) || (v && v.service ? { type: 'service', id: String(v.service) } : null) });
++}
++
function status() {
if (!outbox) return { enabled: false };
return { enabled: true, pending: outbox.pending(), rejected: outbox.rejected(), queued_since_boot: stats.queued, last_error: stats.lastError };
}
function _reset() { if (outbox) outbox.stop(); outbox = null; stats.queued = 0; stats.lastError = null; }
--module.exports = { init, record, pasteCreated, pasteUpdated, pasteDeleted, threadCreated, postCreated, commentCreated, status, _reset };
++module.exports = { init, record, moderationAction, pasteCreated, pasteUpdated, pasteDeleted, threadCreated, postCreated, commentCreated, status, _reset };
ddiff --git a/server/forum/service.js b/server/forum/service.js
iindex b21ac1d..25540c9 100644
---- a/server/forum/service.js
++++ b/server/forum/service.js
@@@ -28,6 +28,7 @@
* gates it to their own members.
*/
const store = require('./store');
++const events = require('../events');
const { applyVote, myVotes, parseVote } = require('../votes');
const { fail, isoTime } = require('../http/v1');
const { createAuthors } = require('../identity/authors');
@@@ -200,8 +201,12 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
}
function removeThread(v, thread) {
-- if (!(person(v) && thread.author_subject === v.subject) && !moderator(v)) fail(403, 'thread.not_yours', 'Only the author or a moderator deletes a thread');
-- store.softDeleteThread(db, thread.id);
++ const mine = person(v) && thread.author_subject === v.subject;
++ if (!mine && !moderator(v)) fail(403, 'thread.not_yours', 'Only the author or a moderator deletes a thread');
++ db.transaction(() => {
++ store.softDeleteThread(db, thread.id);
++ if (!mine) events.moderationAction(v, 'thread.deleted', { type: 'thread', id: String(thread.id), owner_subject: thread.author_subject || null });
++ })();
if (pulse) hook(() => { pulse.threadGone(thread.id); for (const p of db.prepare('SELECT id FROM posts WHERE thread_id = ?').all(thread.id)) pulse.postGone(p.id); });
return { ok: true, id: thread.id, deleted: 'thread' };
}
@@@ -337,7 +342,10 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
const { post, thread } = postFor(v, postId);
if (!(person(v) && post.author_subject === v.subject) && !moderator(v)) fail(403, 'post.not_yours', 'Only the author or a moderator deletes a post');
if (post.is_opening) return removeThread(v, thread);
-- store.softDeletePost(db, post.id);
++ db.transaction(() => {
++ store.softDeletePost(db, post.id);
++ if (!(person(v) && post.author_subject === v.subject)) events.moderationAction(v, 'post.deleted', { type: 'post', id: String(post.id), owner_subject: post.author_subject || null }, { details: { thread: String(thread.id) } });
++ })();
if (pulse) hook(() => pulse.postGone(post.id));
return { ok: true, id: post.id };
},
@@@ -370,7 +378,11 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
if (body.pinned !== undefined) flags.pinned = !!body.pinned;
if (body.locked !== undefined) flags.locked = !!body.locked;
if (!Object.keys(flags).length) fail(400, 'thread.nothing_to_change', 'Send pinned and/or locked');
-- const next = store.setThreadFlags(db, thread.id, flags);
++ const next = db.transaction(() => {
++ const r = store.setThreadFlags(db, thread.id, flags);
++ if (flags.locked !== undefined && !!thread.locked !== flags.locked) events.moderationAction(v, flags.locked ? 'thread.locked' : 'thread.unlocked', { type: 'thread', id: String(thread.id), owner_subject: thread.author_subject || null });
++ return r;
++ })();
const projections = await authors.projectionsFor([next.author_subject]);
return { thread: shapeThread(next, space, v, projections, null) };
},
ddiff --git a/server/pastes/api.js b/server/pastes/api.js
iindex 8bd87d1..203a58a 100644
---- a/server/pastes/api.js
++++ b/server/pastes/api.js
@@@ -113,8 +113,8 @@ function createPastesApi({ service, viewers, anonWriteLimiter: sharedLimiter = n
router.get('/admin/stats', staffOnly, run(() => service.stats()));
router.get('/admin/forks', staffOnly, run((req) => service.forks(req.query)));
-- router.delete('/admin/forks', staffOnly, run(() => service.deleteForks()));
-- router.post('/bulk', staffOnly, json, jsonErrors, run((req) => service.bulk(req.body || {})));
++ router.delete('/admin/forks', staffOnly, run((req) => service.deleteForks(req.viewer)));
++ router.post('/bulk', staffOnly, json, jsonErrors, run((req) => service.bulk(req.body || {}, req.viewer)));
router.post('/screenshot', guard('community.paste.create'), anonWriteLimiter, withFile,
run((req) => service.createScreenshot(req.viewer, req.body || {}, req.file, ctx(req)), 201));
ddiff --git a/server/pastes/service.js b/server/pastes/service.js
iindex 4332373..79cffc7 100644
---- a/server/pastes/service.js
++++ b/server/pastes/service.js
@@@ -17,6 +17,7 @@
* the JSON response, or throws PasteError(status, body).
*/
const crypto = require('crypto');
++const events = require('../events');
const store = require('./store');
const { stripImageMetadata } = require('../media/strip-metadata');
const { capabilities } = require('openvibe-contracts');
@@@ -454,7 +455,12 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
if (body.is_nsfw !== undefined) patch.is_nsfw = truthy(body.is_nsfw) ? 1 : 0;
if (body.pinned !== undefined && isStaff(v)) patch.pinned = truthy(body.pinned) ? 1 : 0;
if (!Object.keys(patch).length) fail(400, 'Nothing to update');
-- const row = store.updatePaste(db, p.id, patch, v.subject || null);
++ const row = db.transaction(() => {
++ const r = store.updatePaste(db, p.id, patch, v.subject || null);
++ // Staff editing someone else's paste goes to the moderation audit log (ADR-022).
++ if (!isOwner(v, p) && isStaff(v)) events.moderationAction(v, patch.visibility !== undefined && Object.keys(patch).length === 1 ? 'paste.visibility_changed' : 'paste.edited', { type: 'paste', id: p.slug, owner_subject: p.owner_subject || null }, { details: { fields: Object.keys(patch) } });
++ return r;
++ })();
tell((pl) => pl.pasteChanged(row));
return { paste: await shapeOne(row, v) };
},
@@@ -464,7 +470,10 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
needIdentity(v);
const p = visible(v, slug);
if (!isOwner(v, p) && !isStaff(v)) fail(403, 'Not authorized for this paste');
-- store.softDelete(db, p.id);
++ db.transaction(() => {
++ store.softDelete(db, p.id);
++ if (!isOwner(v, p)) events.moderationAction(v, 'paste.deleted', { type: 'paste', id: p.slug, owner_subject: p.owner_subject || null });
++ })();
tell((pl) => pl.pasteGone(p.slug));
return { success: true };
},
@@@ -560,7 +569,10 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
if (!c || c.paste_id !== p.id) fail(404, 'Comment not found');
const isAuthor = !!(v.subject && c.author_subject && c.author_subject === v.subject);
if (!isAuthor && !isOwner(v, p) && !isStaff(v)) fail(403, 'Not authorized to delete this comment');
-- store.softDeleteComment(db, c.id);
++ db.transaction(() => {
++ store.softDeleteComment(db, c.id);
++ if (!isAuthor && !isOwner(v, p)) events.moderationAction(v, 'comment.deleted', { type: 'paste_comment', id: String(c.id), owner_subject: c.author_subject || null }, { details: { paste: p.slug } });
++ })();
return { message: 'Comment deleted' };
},
@@@ -585,9 +597,15 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
return { forks: forks.map((f) => ({ ...f, user_id: f.owner_subject || null })), total, limit, offset };
},
-- deleteForks() { return { success: true, deleted: store.deleteAllForks(db) }; },
++ deleteForks(v) {
++ return db.transaction(() => {
++ const deleted = store.deleteAllForks(db);
++ events.moderationAction(v, 'forks.deleted', { type: 'pastes', id: 'forks' }, { details: { deleted } });
++ return { success: true, deleted };
++ })();
++ },
-- bulk(body = {}) {
++ bulk(body = {}, v = null) {
const { slugs, action } = body;
if (!Array.isArray(slugs) || !slugs.length) fail(400, 'No slugs provided');
if (!['delete', 'public', 'unlisted', 'private'].includes(action)) fail(400, 'Invalid action');
@@@ -601,6 +619,7 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
if (action !== 'public') tell((pl) => pl.pasteGone(p.slug));
done++;
}
++ if (done) events.moderationAction(v, 'pastes.bulk', { type: 'pastes', id: 'bulk' }, { details: { bulk_action: action, done, skipped } });
})();
return { done, skipped };
},
@@@ -621,7 +640,11 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
console.warn('[Pastes] censor upload failed:', err.message);
fail(502, 'Media service unavailable');
}
-- const row = store.setScreenshot(db, p.id, stored.url, stored.media_ref);
++ const row = db.transaction(() => {
++ const r = store.setScreenshot(db, p.id, stored.url, stored.media_ref);
++ events.moderationAction(v, 'paste.censored', { type: 'paste', id: p.slug, owner_subject: p.owner_subject || null });
++ return r;
++ })();
return { paste: await shapeOne(row, v) };
},
ddiff --git a/test/events.test.js b/test/events.test.js
iindex 4824dac..6bceb1c 100644
---- a/test/events.test.js
++++ b/test/events.test.js
@@@ -75,6 +75,32 @@ check('a comment on a Live VOD queues community.comment.created with its ref', (
assert.ok(!JSON.stringify(evs).includes('nice stream'));
});
++check('staff actions on someone else\'s content go to the moderation audit log (ADR-022); an owner\'s own do not', () => {
++ const { createPasteService } = require('../server/pastes/service');
++ const svc = createPasteService({ db });
++ const STAFF = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR';
++ pastes.insertPaste(db, { slug: 'mod-target-1', owner_subject: USR, origin: 'user', type: 'paste', title: 't', content: 'secret words', language: 'text', visibility: 'public' });
++ pastes.insertPaste(db, { slug: 'own-paste-1', owner_subject: USR, origin: 'user', type: 'paste', title: 't', content: 'x', language: 'text', visibility: 'public' });
++ const before = queued().length;
++ svc.remove({ kind: 'user', subject: STAFF, staff: true }, 'mod-target-1');
++ svc.remove({ kind: 'user', subject: USR }, 'own-paste-1');
++ const evs = queued().slice(before);
++ const mod = evs.filter((e) => e.event_type === 'community.moderation.action');
++ assert.strictEqual(mod.length, 1, 'only the staff delete is audited');
++ ok(mod[0]);
++ assert.deepStrictEqual(mod[0].payload.target, { type: 'paste', id: 'mod-target-1', owner_subject: USR });
++ assert.strictEqual(mod[0].payload.actor_subject, STAFF);
++ assert.deepStrictEqual(mod[0].actor, { type: 'user', id: STAFF });
++ assert.ok(!JSON.stringify(mod).includes('secret words'), 'never the content');
++ pastes.insertPaste(db, { slug: 'bulk-target-1', owner_subject: USR, origin: 'user', type: 'paste', title: 't', content: 'x', language: 'text', visibility: 'public' });
++ const bulk = svc.bulk({ slugs: ['bulk-target-1', 'nope'], action: 'private' }, { kind: 'user', subject: STAFF, staff: true });
++ assert.deepStrictEqual([bulk.done, bulk.skipped], [1, 1]);
++ const last = queued().slice(-1)[0];
++ assert.strictEqual(last.event_type, 'community.moderation.action');
++ assert.deepStrictEqual(last.payload.details, { bulk_action: 'private', done: bulk.done, skipped: bulk.skipped });
++ ok(last);
++});
++
events._reset();
console.log(`community events: ${n} checks passed`);
process.exit(0);