openvibers/openvibe.reviews Commits

A source item taken down at its source is 410 through /api/v1/items/:id

OP OpenVibers committed 23 Sept 2026 · 9e3fd92f63 · 2 files, +57 −1

GET /api/v1/items/:id kept answering 200 with the full item (title, URL, fields) after
OpenVibe.Sources removed it, and the route is open to people and anonymous callers. A removed item
now answers 410 item.removed, like a deleted entity (API and pages), whoever asks; the row stays
for the record. test/takedown.test.js covers both removal paths (the sources.item.removed webhook
and an item first read as removed).

Changes

ddiff --git a/server/http/api.js b/server/http/api.js
iindex d493e54..abc72eb 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -21,7 +21,7 @@
* POST /entities/:ref/split reviews.entity.split { note? }
* POST /trust reviews.entity.manage { scope, scope_id, key, value, note? }
* GET /items?resolution=queue reviews.entity.resolve items waiting for an editor
-- * GET /items/:id reviews.entity.resolve
++ * GET /items/:id reviews.entity.resolve 410 item.removed once its source took it down
* POST /items/:id/resolution reviews.entity.resolve { entity, add_alias? } | { ignore: true, note? } editor
* POST /signals/import reviews.signal.import { source_item_id } fetch the item from Sources and apply it
* POST /sources/sync reviews.signal.import pull the next pages of review items from Sources
@@@ -104,6 +104,8 @@ function createApi({ svc, viewers, platform, sync, config, log = console }) {
router.get('/items/:id', guard('reviews.entity.resolve'), R((req) => {
const row = svc.item(req.params.id);
if (!row) throw new svc.ReviewsError(404, 'item.not_found', 'Reviews has not read that item');
++ // Taken down at its source: gone, like a deleted entity (its record stays for the audit).
++ if (row.state !== 'active') throw new svc.ReviewsError(410, 'item.removed', 'Its source removed this item');
return { item: svc.itemView(row) };
}));
router.post('/items/:id/resolution', guard('reviews.entity.resolve'), R((req) => {
ddiff --git a/test/takedown.test.js b/test/takedown.test.js
nnew file mode 100644
iindex 0000000..b9fc2c4
---- /dev/null
++++ b/test/takedown.test.js
@@@ -0,0 +1,54 @@
++'use strict';
++/**
++ * Source items taken down at their source answer like the other taken-down surfaces: GET
++ * /api/v1/items/:id is 410 item.removed (as a deleted entity is 410), for every caller, and the
++ * body carries none of the item's content.
++ */
++const assert = require('assert');
++const {
++ boot, req, suite, productItem, importItem, deliver, sourcesEvent, serviceToken, editorToken, readerToken,
++} = require('./helpers');
++
++const t = suite('takedown');
++
++t('a removed source item is 410 through /api/v1/items/:id, whoever asks', async () => {
++ const h = await boot();
++ try {
++ h.sources.addSource({ key: 'shop-example', name: 'Shop example', homepage_url: 'https://shop.example/', category: 'reviews' });
++ const it = productItem({ title: 'Taken Down Widget', url: 'https://shop.example/p/taken-down' });
++ await importItem(h, it);
++ const resolver = serviceToken({ client: 'tools', cap: ['reviews.entity.resolve'] });
++ const callers = [{}, { token: readerToken() }, { token: editorToken() }, { token: resolver }];
++ for (const c of callers) {
++ const r = await req(h, 'GET', `/api/v1/items/${it.id}`, c);
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.json.item.title, 'Taken Down Widget');
++ }
++
++ const removed = h.sources.remove(it.id, 'takedown');
++ const d = await deliver(h, sourcesEvent('sources.item.removed', removed, { reason: 'takedown' }));
++ assert.strictEqual(d.status, 200, d.text);
++ for (const c of callers) {
++ const r = await req(h, 'GET', `/api/v1/items/${it.id}`, c);
++ assert.strictEqual(r.status, 410, r.text);
++ assert.strictEqual(r.json.code, 'item.removed');
++ assert.ok(!r.text.includes('Taken Down Widget') && !r.text.includes('shop.example/p/taken-down'), 'no content of the removed item');
++ }
++ } finally { await h.stop(); }
++});
++
++t('an item Reviews first heard of as removed is 410 too; an unknown one stays 404', async () => {
++ const h = await boot();
++ try {
++ const it = productItem({ title: 'Never Seen', url: 'https://shop.example/p/never-seen' });
++ h.sources.put(it);
++ const removed = h.sources.remove(it.id, 'takedown');
++ const out = await importItem(h, removed);
++ assert.strictEqual(out.outcome, 'removed:unknown');
++ let r = await req(h, 'GET', `/api/v1/items/${it.id}`, { token: editorToken() });
++ assert.strictEqual(r.status, 410, r.text);
++ assert.strictEqual(r.json.code, 'item.removed');
++ r = await req(h, 'GET', '/api/v1/items/itm_01J8Z6Q3KX0000000000000009');
++ assert.strictEqual(r.status, 404);
++ } finally { await h.stop(); }
++});