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

OP OpenVibers committed 26 Sept 2026 · ee4a6075f9 · 6 files, +114 −8


Changes

ddiff --git a/README.md b/README.md
iindex 546dce4..a24f91d 100644
---- a/README.md
++++ b/README.md
@@@ -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/`)
ddiff --git a/package-lock.json b/package-lock.json
iindex 6698966..189949f 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex f09ee90..6d193a6 100644
---- a/package.json
++++ b/package.json
@@@ -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"
ddiff --git a/server/domain/posts.js b/server/domain/posts.js
iindex 6dd6629..dfd091b 100644
---- a/server/domain/posts.js
++++ b/server/domain/posts.js
@@@ -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 = console
unpublish(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 = console
actingSubject(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 });
});
},
ddiff --git a/server/events/outbox.js b/server/events/outbox.js
iindex 24aab16..35afb93 100644
---- a/server/events/outbox.js
++++ b/server/events/outbox.js
@@@ -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(); },
ddiff --git a/test/moderation-events.test.js b/test/moderation-events.test.js
nnew file mode 100644
iindex 0000000..cda4a8e
---- /dev/null
++++ b/test/moderation-events.test.js
@@@ -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();
++})();