openvibers/openvibe.reviews Commits
Summary history hides unreviewed AI drafts, unpublished summaries and deleted entities from readers
Changes
@@@ -343,6 +343,18 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.nowreturn '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.nowhistory(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);},@@@ -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 {
@@ -343,6 +343,18 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
@@ -343,6 +343,18 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
return 'draft';
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) {
function pendingRevisions(summary) {
const head = revisions.headNumber(summary.id);
const head = revisions.headNumber(summary.id);
const out = [];
const out = [];
@@ -886,12 +898,13 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
@@ -886,12 +898,13 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
history(e, actor) {
history(e, actor) {
const editor = access.isEditor(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 summary = q.summary.get(e.id);
const revs = summary ? revisions.list(summary.id, { limit: 200 }) : [];
const revs = summary ? revisions.list(summary.id, { limit: 200 }) : [];
return {
return {
entity: entityView(e),
entity: entityView(e),
aggregates: q.aggregates.all(e.id, 200).map(aggregateView),
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)) : [],
.map((r) => summaryRevisionView(summary, r, e.id)) : [],
merges: q.mergeLinksOf.all(e.id, e.id).map((l) => ({
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,
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
@@ -906,11 +919,12 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
},
},
summaryRevision(e, n, actor) {
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);
const summary = q.summary.get(e.id);
if (!summary) fail(404, 'summary.not_found', 'This entity has no summary');
if (!summary) fail(404, 'summary.not_found', 'This entity has no summary');
const rev = revisions.get(summary.id, Number(n));
const rev = revisions.get(summary.id, Number(n));
if (!rev) fail(404, 'revision.not_found', `No revision ${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}`);
if (!visible) fail(404, 'revision.not_found', `No revision ${n}`);
return summaryRevisionView(summary, rev, e.id);
return summaryRevisionView(summary, rev, e.id);
},
},
@@ -148,6 +148,53 @@ t('human summaries cite signals for every point; a withdrawn cited signal flags
@@ -148,6 +148,53 @@ t('human summaries cite signals for every point; a withdrawn cited signal flags
} finally { await h.stop(); }
} 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 () => {
t('corrections from people queue for editors', async () => {
const h = await boot();
const h = await boot();
try {
try {