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
Changes
@@@ -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",@@@ -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",@@@ -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 };@@@ -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 ───────nnew file mode 100644@@@ -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); });
@@ -16,7 +16,7 @@
@@ -16,7 +16,7 @@
"dotenv": "^16.4.5",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"express": "^4.19.2",
"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.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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.14.0",
"sharp": "^0.33.4"
"sharp": "^0.33.4"
@@ -1919,9 +1919,9 @@
@@ -1919,9 +1919,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"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",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -11,7 +11,7 @@
@@ -11,7 +11,7 @@
},
},
"license": "MIT",
"license": "MIT",
"dependencies": {
"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",
"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/client-s3": "^3.600.0",
"@aws-sdk/lib-storage": "^3.600.0",
"@aws-sdk/lib-storage": "^3.600.0",
@@ -159,6 +159,24 @@ function recordIndexDocument(eventType, subject, payload) {
@@ -159,6 +159,24 @@ function recordIndexDocument(eventType, subject, payload) {
return env;
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
* 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
* service. When the service's outbox table exists (the outbox is on there), the script writes its
@@ -251,4 +269,4 @@ function _reset() {
@@ -251,4 +269,4 @@ function _reset() {
stats.queued = 0; stats.lastError = null;
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 };
@@ -572,12 +572,34 @@ function afterTierMove(vodId, verifiedProviders = [], write = null) {
@@ -572,12 +572,34 @@ function afterTierMove(vodId, verifiedProviders = [], write = null) {
// ── Holds ────────────────────────────────────────────────────
// ── 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). */
/** 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 }) {
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(', ')}`);
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). */
/** 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) {
@@ -587,8 +609,12 @@ function holdPublic(h) {
}
}
function releaseHold(holdId, releasedBy = null) {
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 ───────
// ── Native objects: soft delete, restore, purge, quota ───────
new file mode 100644
new file mode 100644
@@ -0,0 +1,67 @@
@@ -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); });