openvibers/openvibe.blog Commits
Moderation audit (ADR-022, WS-D task 1): blog.moderation.action from the outbox in the same transaction when staff unpublish (post.unpublished) or delete (post.deleted) a post only their staff powers allow, with the author as owner_subject; an author or blog editor doing the same reports nothing. Pins: openvibe-contracts 0.49.0 → 0.53.0
Changes
@@@ -211,6 +211,7 @@ OpenVibe.Network proxies this feed at `openvibe.network/api/v1/changelog`. Every| `blog.post.created` | internal || `blog.post.published`, `.updated`, `.unpublished`, `.deleted` | `openvibe-publishing/index-hooks` `publicationEvent`. Public visibility only for public, listable posts. The payload is the canonical URL, state and indexability, never the body. || `blog.schedule.failed` | internal |++| `blog.moderation.action` | internal. Staff unpublished (`post.unpublished`) or deleted (`post.deleted`) a post that only their staff powers let them touch, for the network's moderation audit log (ADR-022, `common.moderation-action@1`). Never the content. || `blog.index_document.upserted` / `.deleted` | Documents and tombstones in `search.index-document@1` form, with a monotonic index revision (`createIndexSequencer`). Only published, public, listable posts are upserted; every other state is a tombstone. A post that was never indexed gets no tombstone. |### Capabilities (released in openvibe-contracts v0.18.0; proposal: `docs/capabilities-proposal/`)@@@ -15,7 +15,7 @@"express": "^4.21.2","express-rate-limit": "^7.5.0","helmet": "^8.1.0",-- "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-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.4.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"@@@ -1015,9 +1015,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",@@@ -21,7 +21,7 @@"express": "^4.21.2","express-rate-limit": "^7.5.0","helmet": "^8.1.0",-- "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-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.4.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"@@@ -53,6 +53,12 @@ function createPosts({ store, blogs, publication, access, outbox, log = console@allow_comments, @noindex, @now, @now)`),};const blogOf = (post) => blogs.get(post.blog_id);++ // Staff acting where only their staff powers allow it (not a role on the blog, not their own post)++ // is moderation: it also goes to Network's moderation audit log (ADR-022).++ const byStaff = (viewer, blog, action, post) => access.isStaff(viewer) && !access.canWrite(store, { ...viewer, staff: false }, blog, action, post);++ const moderated = (action, post, viewer, ctx, details) => outbox.moderationAction({++ action, target: { type: 'post', id: post.id, owner_subject: post.author_subject }, actorSubject: viewer.subject, details: { blog_id: post.blog_id, ...details },++ }, { traceparent: ctx.traceparent });// ── Input normalisation ─────────────────────────────────@@@ -366,14 +372,19 @@ function createPosts({ store, blogs, publication, access, outbox, log = consoleunpublish(viewer, post, ctx = {}) {actingSubject(viewer);if (!access.canWrite(store, viewer, blogOf(post), 'unpublish', post)) throw new ApiError(403, 'post.forbidden', 'You cannot unpublish this post');++ const moderation = byStaff(viewer, blogOf(post), 'unpublish', post);return store.tx(() => {store.scheduler.cancelPending(post.id);const cur = q.byId.get(post.id);++ let out;if (cur.state === 'scheduled') {db.prepare("UPDATE blog_posts SET state = 'draft', updated_at = ? WHERE id = ?").run(store.now(), post.id);-- return { changed: true, post: q.byId.get(post.id) };++ out = { changed: true, post: q.byId.get(post.id) };++ } else {++ out = api.applyUnpublish(post.id, viewer, ctx);}-- return api.applyUnpublish(post.id, viewer, ctx);++ if (moderation && out.changed) moderated('post.unpublished', cur, viewer, ctx, { previous: cur.state });++ return out;});},@@@ -447,11 +458,14 @@ function createPosts({ store, blogs, publication, access, outbox, log = consoleactingSubject(viewer);const blog = blogOf(post);if (!access.canWrite(store, viewer, blog, 'delete', post)) throw new ApiError(403, 'post.forbidden', 'You cannot delete this post');++ const moderation = byStaff(viewer, blog, 'delete', post);return store.tx(() => {store.scheduler.cancelPending(post.id);-- const before = publication.snapshot(blog, q.byId.get(post.id));++ const cur = q.byId.get(post.id);++ const before = publication.snapshot(blog, cur);const now = store.now();db.prepare("UPDATE blog_posts SET state = 'deleted', deleted_at = ?, updated_at = ? WHERE id = ?").run(now, now, post.id);++ if (moderation && cur.state !== 'deleted') moderated('post.deleted', cur, viewer, ctx, { previous: cur.state });return publication.afterChange(before, post.id, { actor: viewer, ...ctx });});},@@@ -9,6 +9,9 @@* publicationEvent: canonical URL, state, indexability; public* only for public, listable posts)* blog.schedule.failed a scheduled publish/unpublish gave up after its retries++ * blog.moderation.action staff unpublished or deleted a post only their staff powers++ * allowed (common.moderation-action@1, ADR-022), for Network's++ * moderation audit log* blog.index_document.upserted|deleted the OpenVibe.Search document or tombstone (index-hooks* indexEvent), consumed by Search's '*.index_document.*'* subscription@@@ -53,8 +56,24 @@ function createBlogOutbox({ db, config, fetchImpl, now, log = console }) {return outbox.enqueue(envelope, { traceparent });}++ /**++ * blog.moderation.action, inside the caller's transaction. actorSubject: the staff member;++ * target: { type, id, owner_subject? }. Never the content.++ */++ function moderationAction({ action, target, actorSubject, reason = null, details = {} }, { traceparent } = {}) {++ const t = { type: target.type, id: String(target.id).slice(0, 200), owner_subject: target.owner_subject || null };++ return emit({++ event_type: 'blog.moderation.action',++ actor: actorSubject ? { type: 'user', id: actorSubject } : { type: 'service', id: 'blog' },++ subject: { type: 'moderation_action', id: `${t.type}:${t.id}`.slice(0, 200) },++ visibility: 'internal',++ payload: { action, target: t, actor_subject: actorSubject || null, reason: reason ? String(reason).slice(0, 500) : null, details: details || {} },++ }, { traceparent });++ }++return {emit,++ moderationAction,outbox,enabled,start() { if (enabled) outbox.start(); },nnew file mode 100644@@@ -0,0 +1,72 @@++'use strict';++/**++ * blog.moderation.action (ADR-022): staff unpublishing or deleting someone else's post goes to++ * Network's moderation audit log, from the outbox in the same transaction; the author doing the++ * same to their own post reports nothing.++ */++const assert = require('assert');++const contracts = require('openvibe-contracts');++const { boot, check, done } = require('./helpers/boot');++++const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');++++(async () => {++ const t = await boot();++ const dana = t.network.addUser('dana');++ const admin = t.network.addUser('admin', { role: 'admin' });++ await t.get('/api/v1/blogs', { as: dana, json: {} });++ const make = async (title) => {++ const p = (await t.get('/api/v1/blogs/dana/posts', { as: dana, json: { title, body: `${title} ${LONG}` } })).json().post;++ assert.strictEqual((await t.get(`/api/v1/posts/${p.id}/publish`, { as: dana, json: {} })).status, 200);++ return p;++ };++ const moderation = () => t.ctx.store.db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all()++ .map((r) => JSON.parse(r.envelope)).filter((e) => e.event_type === 'blog.moderation.action');++ const valid = (e) => {++ assert.strictEqual(contracts.validate('events.event-envelope@1', e).valid, true, JSON.stringify(e));++ const r = contracts.validate('blog.moderation.action@1', e.payload);++ assert.strictEqual(r.valid, true, JSON.stringify(r.errors));++ assert.strictEqual(e.source, 'blog');++ assert.strictEqual(e.visibility, 'internal');++ assert.deepStrictEqual(e.actor, { type: 'user', id: admin.subject });++ assert.strictEqual(e.payload.actor_subject, admin.subject);++ };++++ await check('the author unpublishing and deleting their own posts reports nothing', async () => {++ const a = await make('Own unpublish');++ const b = await make('Own delete');++ assert.strictEqual((await t.get(`/api/v1/posts/${a.id}/unpublish`, { as: dana, json: {} })).status, 200);++ assert.strictEqual((await t.get(`/api/v1/posts/${b.id}`, { as: dana, method: 'DELETE' })).status < 300, true);++ assert.strictEqual(moderation().length, 0);++ });++++ await check('staff unpublishing someone else\'s post: exactly one valid event', async () => {++ const p = await make('Staff unpublish');++ const r = await t.get(`/api/v1/posts/${p.id}/unpublish`, { as: admin, json: {} });++ assert.strictEqual(r.status, 200, r.text);++ const ev = moderation();++ assert.strictEqual(ev.length, 1);++ valid(ev[0]);++ assert.deepStrictEqual(ev[0].subject, { type: 'moderation_action', id: `post:${p.id}` });++ assert.strictEqual(ev[0].payload.action, 'post.unpublished');++ assert.deepStrictEqual(ev[0].payload.target, { type: 'post', id: p.id, owner_subject: dana.subject });++ assert.strictEqual(ev[0].payload.details.previous, 'published');++ const again = await t.get(`/api/v1/posts/${p.id}/unpublish`, { as: admin, json: {} });++ assert.strictEqual(again.status, 200);++ assert.strictEqual(moderation().length, 1, 'nothing changed, nothing reported');++ });++++ await check('staff deleting someone else\'s post: exactly one valid event', async () => {++ const p = await make('Staff delete');++ const r = await t.get(`/api/v1/posts/${p.id}`, { as: admin, method: 'DELETE' });++ assert.ok(r.status < 300, r.text);++ const ev = moderation();++ assert.strictEqual(ev.length, 2);++ valid(ev[1]);++ assert.strictEqual(ev[1].payload.action, 'post.deleted');++ assert.deepStrictEqual(ev[1].payload.target, { type: 'post', id: p.id, owner_subject: dana.subject });++ });++++ await t.close();++ done();++})();
@@ -211,6 +211,7 @@ OpenVibe.Network proxies this feed at `openvibe.network/api/v1/changelog`. Every
@@ -211,6 +211,7 @@ OpenVibe.Network proxies this feed at `openvibe.network/api/v1/changelog`. Every
| `blog.post.created` | internal |
| `blog.post.created` | internal |
| `blog.post.published`, `.updated`, `.unpublished`, `.deleted` | `openvibe-publishing/index-hooks` `publicationEvent`. Public visibility only for public, listable posts. The payload is the canonical URL, state and indexability, never the body. |
| `blog.post.published`, `.updated`, `.unpublished`, `.deleted` | `openvibe-publishing/index-hooks` `publicationEvent`. Public visibility only for public, listable posts. The payload is the canonical URL, state and indexability, never the body. |
| `blog.schedule.failed` | internal |
| `blog.schedule.failed` | internal |
+| `blog.moderation.action` | internal. Staff unpublished (`post.unpublished`) or deleted (`post.deleted`) a post that only their staff powers let them touch, for the network's moderation audit log (ADR-022, `common.moderation-action@1`). Never the content. |
| `blog.index_document.upserted` / `.deleted` | Documents and tombstones in `search.index-document@1` form, with a monotonic index revision (`createIndexSequencer`). Only published, public, listable posts are upserted; every other state is a tombstone. A post that was never indexed gets no tombstone. |
| `blog.index_document.upserted` / `.deleted` | Documents and tombstones in `search.index-document@1` form, with a monotonic index revision (`createIndexSequencer`). Only published, public, listable posts are upserted; every other state is a tombstone. A post that was never indexed gets no tombstone. |
### Capabilities (released in openvibe-contracts v0.18.0; proposal: `docs/capabilities-proposal/`)
### Capabilities (released in openvibe-contracts v0.18.0; proposal: `docs/capabilities-proposal/`)
@@ -15,7 +15,7 @@
@@ -15,7 +15,7 @@
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
- "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-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.4.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.4.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.13.0"
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.13.0"
@@ -1015,9 +1015,9 @@
@@ -1015,9 +1015,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"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",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -21,7 +21,7 @@
@@ -21,7 +21,7 @@
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
- "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-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.4.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.4.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.13.0"
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.13.0"
@@ -53,6 +53,12 @@ function createPosts({ store, blogs, publication, access, outbox, log = console
@@ -53,6 +53,12 @@ function createPosts({ store, blogs, publication, access, outbox, log = console
@allow_comments, @noindex, @now, @now)`),
@allow_comments, @noindex, @now, @now)`),
};
};
const blogOf = (post) => blogs.get(post.blog_id);
const blogOf = (post) => blogs.get(post.blog_id);
+ // Staff acting where only their staff powers allow it (not a role on the blog, not their own post)
+ // is moderation: it also goes to Network's moderation audit log (ADR-022).
+ const byStaff = (viewer, blog, action, post) => access.isStaff(viewer) && !access.canWrite(store, { ...viewer, staff: false }, blog, action, post);
+ const moderated = (action, post, viewer, ctx, details) => outbox.moderationAction({
+ action, target: { type: 'post', id: post.id, owner_subject: post.author_subject }, actorSubject: viewer.subject, details: { blog_id: post.blog_id, ...details },
+ }, { traceparent: ctx.traceparent });
// ── Input normalisation ─────────────────────────────────
// ── Input normalisation ─────────────────────────────────
@@ -366,14 +372,19 @@ function createPosts({ store, blogs, publication, access, outbox, log = console
@@ -366,14 +372,19 @@ function createPosts({ store, blogs, publication, access, outbox, log = console
unpublish(viewer, post, ctx = {}) {
unpublish(viewer, post, ctx = {}) {
actingSubject(viewer);
actingSubject(viewer);
if (!access.canWrite(store, viewer, blogOf(post), 'unpublish', post)) throw new ApiError(403, 'post.forbidden', 'You cannot unpublish this post');
if (!access.canWrite(store, viewer, blogOf(post), 'unpublish', post)) throw new ApiError(403, 'post.forbidden', 'You cannot unpublish this post');
+ const moderation = byStaff(viewer, blogOf(post), 'unpublish', post);
return store.tx(() => {
return store.tx(() => {
store.scheduler.cancelPending(post.id);
store.scheduler.cancelPending(post.id);
const cur = q.byId.get(post.id);
const cur = q.byId.get(post.id);
+ let out;
if (cur.state === 'scheduled') {
if (cur.state === 'scheduled') {
db.prepare("UPDATE blog_posts SET state = 'draft', updated_at = ? WHERE id = ?").run(store.now(), post.id);
db.prepare("UPDATE blog_posts SET state = 'draft', updated_at = ? WHERE id = ?").run(store.now(), post.id);
- return { changed: true, post: q.byId.get(post.id) };
+ out = { changed: true, post: q.byId.get(post.id) };
+ } else {
+ out = api.applyUnpublish(post.id, viewer, ctx);
}
}
- return api.applyUnpublish(post.id, viewer, ctx);
+ if (moderation && out.changed) moderated('post.unpublished', cur, viewer, ctx, { previous: cur.state });
+ return out;
});
});
},
},
@@ -447,11 +458,14 @@ function createPosts({ store, blogs, publication, access, outbox, log = console
@@ -447,11 +458,14 @@ function createPosts({ store, blogs, publication, access, outbox, log = console
actingSubject(viewer);
actingSubject(viewer);
const blog = blogOf(post);
const blog = blogOf(post);
if (!access.canWrite(store, viewer, blog, 'delete', post)) throw new ApiError(403, 'post.forbidden', 'You cannot delete this post');
if (!access.canWrite(store, viewer, blog, 'delete', post)) throw new ApiError(403, 'post.forbidden', 'You cannot delete this post');
+ const moderation = byStaff(viewer, blog, 'delete', post);
return store.tx(() => {
return store.tx(() => {
store.scheduler.cancelPending(post.id);
store.scheduler.cancelPending(post.id);
- const before = publication.snapshot(blog, q.byId.get(post.id));
+ const cur = q.byId.get(post.id);
+ const before = publication.snapshot(blog, cur);
const now = store.now();
const now = store.now();
db.prepare("UPDATE blog_posts SET state = 'deleted', deleted_at = ?, updated_at = ? WHERE id = ?").run(now, now, post.id);
db.prepare("UPDATE blog_posts SET state = 'deleted', deleted_at = ?, updated_at = ? WHERE id = ?").run(now, now, post.id);
+ if (moderation && cur.state !== 'deleted') moderated('post.deleted', cur, viewer, ctx, { previous: cur.state });
return publication.afterChange(before, post.id, { actor: viewer, ...ctx });
return publication.afterChange(before, post.id, { actor: viewer, ...ctx });
});
});
},
},
@@ -9,6 +9,9 @@
@@ -9,6 +9,9 @@
* publicationEvent: canonical URL, state, indexability; public
* publicationEvent: canonical URL, state, indexability; public
* only for public, listable posts)
* only for public, listable posts)
* blog.schedule.failed a scheduled publish/unpublish gave up after its retries
* blog.schedule.failed a scheduled publish/unpublish gave up after its retries
+ * blog.moderation.action staff unpublished or deleted a post only their staff powers
+ * allowed (common.moderation-action@1, ADR-022), for Network's
+ * moderation audit log
* blog.index_document.upserted|deleted the OpenVibe.Search document or tombstone (index-hooks
* blog.index_document.upserted|deleted the OpenVibe.Search document or tombstone (index-hooks
* indexEvent), consumed by Search's '*.index_document.*'
* indexEvent), consumed by Search's '*.index_document.*'
* subscription
* subscription
@@ -53,8 +56,24 @@ function createBlogOutbox({ db, config, fetchImpl, now, log = console }) {
@@ -53,8 +56,24 @@ function createBlogOutbox({ db, config, fetchImpl, now, log = console }) {
return outbox.enqueue(envelope, { traceparent });
return outbox.enqueue(envelope, { traceparent });
}
}
+ /**
+ * blog.moderation.action, inside the caller's transaction. actorSubject: the staff member;
+ * target: { type, id, owner_subject? }. Never the content.
+ */
+ function moderationAction({ action, target, actorSubject, reason = null, details = {} }, { traceparent } = {}) {
+ const t = { type: target.type, id: String(target.id).slice(0, 200), owner_subject: target.owner_subject || null };
+ return emit({
+ event_type: 'blog.moderation.action',
+ actor: actorSubject ? { type: 'user', id: actorSubject } : { type: 'service', id: 'blog' },
+ subject: { type: 'moderation_action', id: `${t.type}:${t.id}`.slice(0, 200) },
+ visibility: 'internal',
+ payload: { action, target: t, actor_subject: actorSubject || null, reason: reason ? String(reason).slice(0, 500) : null, details: details || {} },
+ }, { traceparent });
+ }
+
return {
return {
emit,
emit,
+ moderationAction,
outbox,
outbox,
enabled,
enabled,
start() { if (enabled) outbox.start(); },
start() { if (enabled) outbox.start(); },
new file mode 100644
new file mode 100644
@@ -0,0 +1,72 @@
@@ -0,0 +1,72 @@
+'use strict';
+/**
+ * blog.moderation.action (ADR-022): staff unpublishing or deleting someone else's post goes to
+ * Network's moderation audit log, from the outbox in the same transaction; the author doing the
+ * same to their own post reports nothing.
+ */
+const assert = require('assert');
+const contracts = require('openvibe-contracts');
+const { boot, check, done } = require('./helpers/boot');
+
+const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');
+
+(async () => {
+ const t = await boot();
+ const dana = t.network.addUser('dana');
+ const admin = t.network.addUser('admin', { role: 'admin' });
+ await t.get('/api/v1/blogs', { as: dana, json: {} });
+ const make = async (title) => {
+ const p = (await t.get('/api/v1/blogs/dana/posts', { as: dana, json: { title, body: `${title} ${LONG}` } })).json().post;
+ assert.strictEqual((await t.get(`/api/v1/posts/${p.id}/publish`, { as: dana, json: {} })).status, 200);
+ return p;
+ };
+ const moderation = () => t.ctx.store.db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all()
+ .map((r) => JSON.parse(r.envelope)).filter((e) => e.event_type === 'blog.moderation.action');
+ const valid = (e) => {
+ assert.strictEqual(contracts.validate('events.event-envelope@1', e).valid, true, JSON.stringify(e));
+ const r = contracts.validate('blog.moderation.action@1', e.payload);
+ assert.strictEqual(r.valid, true, JSON.stringify(r.errors));
+ assert.strictEqual(e.source, 'blog');
+ assert.strictEqual(e.visibility, 'internal');
+ assert.deepStrictEqual(e.actor, { type: 'user', id: admin.subject });
+ assert.strictEqual(e.payload.actor_subject, admin.subject);
+ };
+
+ await check('the author unpublishing and deleting their own posts reports nothing', async () => {
+ const a = await make('Own unpublish');
+ const b = await make('Own delete');
+ assert.strictEqual((await t.get(`/api/v1/posts/${a.id}/unpublish`, { as: dana, json: {} })).status, 200);
+ assert.strictEqual((await t.get(`/api/v1/posts/${b.id}`, { as: dana, method: 'DELETE' })).status < 300, true);
+ assert.strictEqual(moderation().length, 0);
+ });
+
+ await check('staff unpublishing someone else\'s post: exactly one valid event', async () => {
+ const p = await make('Staff unpublish');
+ const r = await t.get(`/api/v1/posts/${p.id}/unpublish`, { as: admin, json: {} });
+ assert.strictEqual(r.status, 200, r.text);
+ const ev = moderation();
+ assert.strictEqual(ev.length, 1);
+ valid(ev[0]);
+ assert.deepStrictEqual(ev[0].subject, { type: 'moderation_action', id: `post:${p.id}` });
+ assert.strictEqual(ev[0].payload.action, 'post.unpublished');
+ assert.deepStrictEqual(ev[0].payload.target, { type: 'post', id: p.id, owner_subject: dana.subject });
+ assert.strictEqual(ev[0].payload.details.previous, 'published');
+ const again = await t.get(`/api/v1/posts/${p.id}/unpublish`, { as: admin, json: {} });
+ assert.strictEqual(again.status, 200);
+ assert.strictEqual(moderation().length, 1, 'nothing changed, nothing reported');
+ });
+
+ await check('staff deleting someone else\'s post: exactly one valid event', async () => {
+ const p = await make('Staff delete');
+ const r = await t.get(`/api/v1/posts/${p.id}`, { as: admin, method: 'DELETE' });
+ assert.ok(r.status < 300, r.text);
+ const ev = moderation();
+ assert.strictEqual(ev.length, 2);
+ valid(ev[1]);
+ assert.strictEqual(ev[1].payload.action, 'post.deleted');
+ assert.deepStrictEqual(ev[1].payload.target, { type: 'post', id: p.id, owner_subject: dana.subject });
+ });
+
+ await t.close();
+ done();
+})();