openvibers/openvibe.reviews Commits

Summary history hides unreviewed AI drafts, unpublished summaries and deleted entities from readers

OP OpenVibers committed 23 Sept 2026 · 569986be98 · 2 files, +63 −2

A reader could open /e/:slug/summary/:n, /e/:slug/history and the matching /api/v1 routes and read:
- an AI-proposed summary revision no person ever approved, once any later revision was published
  (it counted as "superseded");
- a summary after an editor unpublished it (published_revision stays set, so every revision up to
  it stayed readable);
- the full history (audit notes, signals, summary revisions) of a deleted entity through the API,
  while its pages answer 410.

Readers now see summary revisions only while the summary is published, up to the published one,
never a rejected revision or an AI revision without an approving review; a deleted entity's history
and summary revisions answer 410 unless the caller is an editor.

Changes

ddiff --git a/server/reviews/service.js b/server/reviews/service.js
iindex 6136429..b2def36 100644
---- a/server/reviews/service.js
++++ b/server/reviews/service.js
@@@ -343,6 +343,18 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
return 'draft';
}
++ /**
++ * What a reader may see of a summary's history: revisions up to the published one while the
++ * summary is published, never a rejected revision or an AI draft no person approved.
++ */
++ function publicRevision(summary, rev) {
++ if (!summary || summary.state !== 'published' || !summary.published_revision || rev.number > summary.published_revision) return false;
++ const review = reviews.latest(summary.id, rev.number);
++ if (review && review.decision === 'rejected') return false;
++ const rec = rev.meta && rev.meta.authorship;
++ return !(rec && !authorship.canPublish(rec, review).ok);
++ }
++
function pendingRevisions(summary) {
const head = revisions.headNumber(summary.id);
const out = [];
@@@ -886,12 +898,13 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
history(e, actor) {
const editor = access.isEditor(actor);
++ if (e.state === 'deleted' && !editor) fail(410, 'entity.deleted', 'This entity was deleted');
const summary = q.summary.get(e.id);
const revs = summary ? revisions.list(summary.id, { limit: 200 }) : [];
return {
entity: entityView(e),
aggregates: q.aggregates.all(e.id, 200).map(aggregateView),
-- summary_revisions: summary ? revs.filter((r) => editor || (summary.published_revision && r.number <= summary.published_revision && revisionStatus(summary, r) !== 'rejected'))
++ summary_revisions: summary ? revs.filter((r) => editor || publicRevision(summary, r))
.map((r) => summaryRevisionView(summary, r, e.id)) : [],
merges: q.mergeLinksOf.all(e.id, e.id).map((l) => ({
id: l.id, from: entityView(q.entity.get(l.from_entity)), to: entityView(q.entity.get(l.to_entity)), note: l.note,
@@@ -906,11 +919,12 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
},
summaryRevision(e, n, actor) {
++ if (e.state === 'deleted' && !access.isEditor(actor)) fail(410, 'entity.deleted', 'This entity was deleted');
const summary = q.summary.get(e.id);
if (!summary) fail(404, 'summary.not_found', 'This entity has no summary');
const rev = revisions.get(summary.id, Number(n));
if (!rev) fail(404, 'revision.not_found', `No revision ${n}`);
-- const visible = access.isEditor(actor) || (summary.published_revision && rev.number <= summary.published_revision && revisionStatus(summary, rev) !== 'rejected');
++ const visible = access.isEditor(actor) || publicRevision(summary, rev);
if (!visible) fail(404, 'revision.not_found', `No revision ${n}`);
return summaryRevisionView(summary, rev, e.id);
},
ddiff --git a/test/summaries.test.js b/test/summaries.test.js
iindex 8e86499..dd4d6f3 100644
---- a/test/summaries.test.js
++++ b/test/summaries.test.js
@@@ -148,6 +148,53 @@ t('human summaries cite signals for every point; a withdrawn cited signal flags
} finally { await h.stop(); }
});
++t('readers never see an unreviewed AI draft, an unpublished summary, or a deleted entity through the revision history', async () => {
++ const h = await boot();
++ try {
++ const { e, pos, neg } = await setup(h);
++ const secret = 'Unreviewed model text that no person approved';
++ const p = await req(h, 'POST', `/api/v1/entities/${e.slug}/summary/proposals`, { token: AI(), body: { overview: secret, overview_signals: pos, pros: [{ text: secret, signals: pos }], workflow: WF } });
++ assert.strictEqual(p.status, 201, p.text);
++ // An editor writes and publishes their own revision instead of reviewing the AI draft.
++ const w = await req(h, 'POST', `/api/v1/entities/${e.slug}/summary/revisions`, { token: editorToken(), body: {
++ overview: 'Most sampled Steam reviewers recommend it.', overview_signals: pos, cons: [{ text: 'One does not', signals: neg }], publish: true,
++ } });
++ assert.strictEqual(w.status, 201, w.text);
++ assert.strictEqual(w.json.revision.number, 2);
++
++ for (const path of [`/e/${e.slug}/summary/1`, `/api/v1/entities/${e.slug}/summary/revisions/1`]) {
++ const r = await req(h, 'GET', path);
++ assert.strictEqual(r.status, 404, `${path} shows the never-reviewed AI draft`);
++ assert.ok(!r.text.includes(secret), path);
++ }
++ for (const path of [`/e/${e.slug}/history`, `/api/v1/entities/${e.slug}/history`]) {
++ const r = await req(h, 'GET', path);
++ assert.ok(!r.text.includes(secret), `${path} lists the never-reviewed AI draft`);
++ }
++ // Editors still see it.
++ assert.strictEqual((await req(h, 'GET', `/api/v1/entities/${e.slug}/summary/revisions/1`, { token: editorToken() })).status, 200);
++
++ // Unpublishing takes the summary down everywhere, including its revision pages.
++ const un = await req(h, 'POST', `/api/v1/entities/${e.slug}/summary/unpublish`, { token: editorToken() });
++ assert.strictEqual(un.status, 200, un.text);
++ for (const path of [`/e/${e.slug}/summary/2`, `/api/v1/entities/${e.slug}/summary/revisions/2`]) {
++ assert.strictEqual((await req(h, 'GET', path)).status, 404, `${path} still serves the unpublished summary`);
++ }
++ const hist = await req(h, 'GET', `/api/v1/entities/${e.slug}/history`);
++ assert.strictEqual(hist.json.summary_revisions.length, 0, 'history still lists the unpublished summary');
++
++ // A deleted entity answers 410 on its pages, and the API does not serve its history either.
++ const gone = await createEntity(h, { name: 'Removed On Request', kind: 'other', aliases: [{ type: 'url', value: 'https://example.org/private-person' }] });
++ const del = await req(h, 'DELETE', `/api/v1/entities/${gone.slug}`, { token: editorToken(), body: { note: 'legal request' } });
++ assert.strictEqual(del.status, 200, del.text);
++ assert.strictEqual((await req(h, 'GET', `/e/${gone.slug}/history`)).status, 410);
++ const api = await req(h, 'GET', `/api/v1/entities/${gone.id}/history`);
++ assert.strictEqual(api.status, 410, 'the API serves a deleted entity\'s history');
++ assert.ok(!api.text.includes('legal request'));
++ assert.strictEqual((await req(h, 'GET', `/api/v1/entities/${gone.id}/history`, { token: editorToken() })).status, 200);
++ } finally { await h.stop(); }
++});
++
t('corrections from people queue for editors', async () => {
const h = await boot();
try {