openvibers/openvibe.media Commits

Moderation audit (WS-D task 1): placing or releasing a staff hold (moderation, dmca, admin, evidence) announces media.moderation.action (common.moderation-action@1) in the same transaction, the staff subject as actor when named; a creator's own pin, a repeat release and sandbox tenants announce nothing; contracts 0.54.0

OP OpenVibers committed 26 Sept 2026 · c90ea8868f · 5 files, +122 −11


Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex 92a2601..44b82f6 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -16,7 +16,7 @@
"dotenv": "^16.4.5",
"express": "^4.19.2",
"multer": "^1.4.5-lts.1",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.52.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.54.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.14.0",
"sharp": "^0.33.4"
@@@ -1919,9 +1919,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.52.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.52.0",
-- "integrity": "sha512-EVkhyQwoA291JmH9K1Yt9lddL1SJgjJxXB2ej7mkCjGaHnn7GHuSyXddwmd8SS6WWxvZvrV8w+glZaGTRwz/Pg==",
++ "version": "0.54.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.54.0",
++ "integrity": "sha512-jOYCWRtK6LWvsc1u1XL85GzH93EMm4qXjAHOnZp9hP3d8kxg0pQc25yuHWLhh3tYR5e4BH2pz0opKrkpwBtu5w==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex e62d4af..580a598 100644
---- a/package.json
++++ b/package.json
@@@ -11,7 +11,7 @@
},
"license": "MIT",
"dependencies": {
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.52.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.54.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
"@aws-sdk/client-s3": "^3.600.0",
"@aws-sdk/lib-storage": "^3.600.0",
ddiff --git a/server/events.js b/server/events.js
iindex 50efc59..ffea486 100644
---- a/server/events.js
++++ b/server/events.js
@@@ -159,6 +159,24 @@ function recordIndexDocument(eventType, subject, payload) {
return env;
}
++/**
++ * Queue a staff action on someone else's media (media.moderation.action, common.moderation-action@1) for
++ * Network's moderation audit. Same rule as record(): inside the transaction that performs the action.
++ * Sandbox tenants announce nothing.
++ */
++function recordModeration(appId, payload) {
++ if (!outbox) return null;
++ if (appId && db.isSandboxTenant(appId)) return null;
++ const env = outbox.enqueue({
++ event_type: 'media.moderation.action',
++ actor: payload.actor_subject ? { type: 'user', id: payload.actor_subject } : { type: 'service', id: 'media' },
++ subject: { type: 'moderation_action', id: `${payload.target.type}:${payload.target.id}` },
++ visibility: 'internal', priority: 'important', payload,
++ });
++ stats.queued++;
++ return env;
++}
++
/**
* Operator scripts (scripts/media-jobs.js) change job state in the same database as the running
* service. When the service's outbox table exists (the outbox is on there), the script writes its
@@@ -251,4 +269,4 @@ function _reset() {
stats.queued = 0; stats.lastError = null;
}
--module.exports = { init, initWriter, record, recordIndexDocument, recordJob, recordObjectChanges, drainObjectChanges, discardObjectChanges, objectChangeEvent, kick, status, TYPES, JOB_TRANSITIONS, _reset };
++module.exports = { init, initWriter, record, recordIndexDocument, recordModeration, recordJob, recordObjectChanges, drainObjectChanges, discardObjectChanges, objectChangeEvent, kick, status, TYPES, JOB_TRANSITIONS, _reset };
ddiff --git a/server/objects/model.js b/server/objects/model.js
iindex 983e655..4e785e3 100644
---- a/server/objects/model.js
++++ b/server/objects/model.js
@@@ -572,12 +572,34 @@ function afterTierMove(vodId, verifiedProviders = [], write = null) {
// ── Holds ────────────────────────────────────────────────────
++/**
++ * A staff hold placed or released is a moderation action (media.moderation.action, ADR-022): announced in
++ * the transaction that records it, for Network's moderation audit. A creator's own pin is not moderation.
++ */
++function announceHold(action, hold, by) {
++ if (!hold || hold.kind === 'creator_pin') return;
++ const obj = getObject(hold.object_id);
++ if (!obj) return;
++ const who = String(by || '');
++ require('../events').recordModeration(obj.app_id, {
++ action,
++ target: { type: obj.kind, id: obj.id, ...(obj.owner_subject ? { owner_subject: obj.owner_subject } : {}) },
++ actor_subject: /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(who) ? who : null,
++ ...(hold.reason ? { reason: String(hold.reason).slice(0, 500) } : {}),
++ details: { hold_id: hold.id, kind: hold.kind, app: obj.app_id, ...(who && !who.startsWith('usr_') ? { by: who.slice(0, 200) } : {}) },
++ });
++}
++
/** Place a hold. created_by (or placed_by) is who placed it; note is free text for staff (≤ 2000). */
function placeHold({ object_id, kind, reason = '', created_by = null, placed_by = null, note = null }) {
if (!HOLD_KINDS.includes(kind)) throw new Error(`hold kind must be one of ${HOLD_KINDS.join(', ')}`);
-- const r = db.run('INSERT INTO media_holds (object_id, kind, reason, created_by, note) VALUES (?, ?, ?, ?, ?)',
-- [object_id, kind, String(reason || '').slice(0, 1000), placed_by ?? created_by, note == null || note === '' ? null : String(note).slice(0, 2000)]);
-- return db.get('SELECT * FROM media_holds WHERE id = ?', [r.lastInsertRowid]);
++ return db.getDb().transaction(() => {
++ const r = db.run('INSERT INTO media_holds (object_id, kind, reason, created_by, note) VALUES (?, ?, ?, ?, ?)',
++ [object_id, kind, String(reason || '').slice(0, 1000), placed_by ?? created_by, note == null || note === '' ? null : String(note).slice(0, 2000)]);
++ const hold = db.get('SELECT * FROM media_holds WHERE id = ?', [r.lastInsertRowid]);
++ announceHold('hold.placed', hold, placed_by ?? created_by);
++ return hold;
++ })();
}
/** A hold as the APIs answer it: the row, plus placed_by / placed_at (the names staff tools use for created_by / created_at). */
@@@ -587,8 +609,12 @@ function holdPublic(h) {
}
function releaseHold(holdId, releasedBy = null) {
-- db.run('UPDATE media_holds SET released_at = CURRENT_TIMESTAMP, released_by = ? WHERE id = ? AND released_at IS NULL', [releasedBy, holdId]);
-- return db.get('SELECT * FROM media_holds WHERE id = ?', [holdId]);
++ return db.getDb().transaction(() => {
++ const r = db.run('UPDATE media_holds SET released_at = CURRENT_TIMESTAMP, released_by = ? WHERE id = ? AND released_at IS NULL', [releasedBy, holdId]);
++ const hold = db.get('SELECT * FROM media_holds WHERE id = ?', [holdId]);
++ if (r.changes) announceHold('hold.released', hold, releasedBy);
++ return hold;
++ })();
}
// ── Native objects: soft delete, restore, purge, quota ───────
ddiff --git a/test/moderation-events.test.js b/test/moderation-events.test.js
nnew file mode 100644
iindex 0000000..591acdb
---- /dev/null
++++ b/test/moderation-events.test.js
@@@ -0,0 +1,67 @@
++'use strict';
++// Staff holds are moderation actions (media.moderation.action, ADR-022, WS-D task 1): placing and
++// releasing a hold on someone else's media queues exactly one valid common.moderation-action@1 in the same
++// transaction, with the staff subject as actor when one is named; a creator's own pin, a second release
++// and a sandbox tenant queue nothing.
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-modevents-'));
++const dir = (n) => { const d = path.join(tmp, n); fs.mkdirSync(d, { recursive: true }); return d; };
++Object.assign(process.env, {
++ DB_PATH: path.join(tmp, 'media.db'), VOD_PATH: dir('vods'), CLIPS_PATH: dir('clips'), FILES_PATH: dir('files'),
++ THUMBNAILS_PATH: dir('thumbnails'), PASTES_PATH: dir('pastes'), OBJECTS_PATH: dir('objects'),
++});
++const stub = http.createServer((req, res) => { req.resume(); req.on('end', () => { res.setHeader('Content-Type', 'application/json'); res.statusCode = 404; res.end('{}'); }); });
++
++(async () => {
++ await new Promise((r) => stub.listen(0, '127.0.0.1', r));
++ const base = `http://127.0.0.1:${stub.address().port}`;
++ const contracts = require('openvibe-contracts');
++ const db = require('../server/db/database');
++ const conn = db.getDb();
++ db.upsertApp({ app_id: 'live', api_key: 'live-key-modev' });
++ const events = require('../server/events');
++ events.init({ eventsUrl: base, clientSecret: 's', networkUrl: base, intervalMs: 60000 });
++ const model = require('../server/objects/model');
++ const moderation = () => conn.prepare('SELECT envelope FROM event_outbox ORDER BY id').all().map((r) => JSON.parse(r.envelope)).filter((e) => e.event_type === 'media.moderation.action');
++
++ const OWNER = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR', STAFF = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';
++ const obj = model.createObject({ app_id: 'live', kind: 'vod', owner_subject: OWNER, lifecycle_status: 'ready', mime_type: 'video/webm', size_bytes: 1 });
++
++ const hold = model.placeHold({ object_id: obj, kind: 'moderation', reason: 'reported for review', placed_by: STAFF, note: 'ticket 12' });
++ let ev = moderation();
++ assert.strictEqual(ev.length, 1, 'one event for the placed hold');
++ const e = ev[0];
++ assert.ok(contracts.validate('media.moderation.action@1', e.payload).valid, JSON.stringify(contracts.validate('media.moderation.action@1', e.payload).errors));
++ assert.deepStrictEqual(e.payload.target, { type: 'vod', id: obj, owner_subject: OWNER });
++ assert.deepStrictEqual([e.payload.action, e.payload.actor_subject, e.payload.reason, e.payload.details.kind, e.payload.details.hold_id], ['hold.placed', STAFF, 'reported for review', 'moderation', hold.id]);
++ assert.deepStrictEqual([e.actor, e.subject, e.visibility], [{ type: 'user', id: STAFF }, { type: 'moderation_action', id: `vod:${obj}` }, 'internal']);
++ assert.ok(!JSON.stringify(e.payload).includes('ticket 12'), 'the staff note stays in Media');
++
++ model.releaseHold(hold.id, 'app:live');
++ ev = moderation();
++ assert.strictEqual(ev.length, 2);
++ assert.deepStrictEqual([ev[1].payload.action, ev[1].payload.actor_subject, ev[1].payload.details.by, ev[1].actor], ['hold.released', null, 'app:live', { type: 'service', id: 'media' }]);
++ model.releaseHold(hold.id, STAFF);
++ assert.strictEqual(moderation().length, 2, 'releasing twice announces once');
++
++ model.placeHold({ object_id: obj, kind: 'creator_pin', reason: 'my favourite', placed_by: OWNER });
++ assert.strictEqual(moderation().length, 2, "a creator's own pin is not moderation");
++
++ // A developer sandbox tenant announces nothing.
++ db.upsertApp({ app_id: 'app.p_sandbox', api_key: 'sandbox-key' });
++ const sandbox = db.isSandboxTenant('app.p_sandbox');
++ if (sandbox) {
++ const so = model.createObject({ app_id: 'app.p_sandbox', kind: 'file', lifecycle_status: 'ready', size_bytes: 1 });
++ model.placeHold({ object_id: so, kind: 'admin', reason: 'x', placed_by: STAFF });
++ assert.strictEqual(moderation().length, 2, 'sandbox tenants produce no platform events');
++ }
++
++ events._reset();
++ stub.close();
++ console.log('moderation events: all checks passed');
++ process.exit(0);
++})().catch((err) => { console.error(err); process.exit(1); });