openvibers/openvibe.media Commits

Media: retire legacy Community v1 files for the screenshot move (#325)

OP OpenVibers committed 7 days ago · 6248c3b1e0 · 4 files, +63 −22

The internal legacy-paste retire route accepted only a Live-era paste screenshot. The screenshot move also has to retire older Community-era bytes: app community, kind file, legacy_ref legacy:community:file:<key>, which the direct v2 delete refuses with 409 media.object.legacy_managed. Accept that second shape for the same svc:community principal with the same soft delete, retention hold and idempotence. Test the new shape, its 410 and deletion event, and a hold on it.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex bfab28a..3946670 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -13,6 +13,12 @@
(`media.object.delete`, principal `svc:community`) and soft-deletes exactly a `legacy:live:paste:<slug>`
screenshot: 410 at `/o/:id`, the ordinary retention window and `media.object.deleted` event, and a repeat
is a no-op. VODs, clips, files, thumbnails, avatars and native objects are refused, and so is any other principal.
++
++- The legacy retire route also accepts a Community-era v1 file object (`app_id community`, kind `file`,
++ `legacy_ref legacy:community:file:<key>`), for the same `svc:community` principal and with the same ordinary soft
++ delete, retention, hold refusal and idempotence. Community's screenshot move calls it for old pastes whose bytes were
++ stored before the Object API v2, after checking no other paste, post or attachment still references the id.
++ Test: `test/legacy-paste-retire.test.js`.
- Review follow-up: a category-3 result places an `evidence` hold in the quarantine transaction, so owner delete and
retention purge answer 409 held; an operator's overruling category releases it with who and when. Adds Vision's
logged 60-second `operator-view` and service-only `review-decision` routes, a separate operator signature purpose,
ddiff --git a/docs/object-model.md b/docs/object-model.md
iindex 55e1e15..dad1542 100644
---- a/docs/object-model.md
++++ b/docs/object-model.md
@@@ -60,9 +60,10 @@ the transitional `media.media-ref@1` legacy form in `legacy_ref`, which is uniqu
Legacy screenshot and avatar objects cannot be deleted through the direct v2 delete route
(`DELETE` answers 409 `media.object.legacy_managed`). Account deletion removes them. One narrow
exception is the internal `POST /internal/community/legacy-paste-screenshots/:id/retire` route: it
--soft-deletes only a `legacy:live:paste:<slug>` screenshot for the Community service principal
--(`svc:community`, capability `media.object.delete`), so Community can retire the old public object
--of a screenshot paste it has re-hosted privately (`server/objects/legacy-paste-retire.js`).
++soft-deletes a `legacy:live:paste:<slug>` screenshot or a `legacy:community:file:<key>` file for the
++Community service principal (`svc:community`, capability `media.object.delete`), so Community can
++retire the old public object of a screenshot paste it has re-hosted privately
++(`server/objects/legacy-paste-retire.js`).
Every v2 route that takes `:id` accepts either form. Native v2 objects have `legacy_ref = NULL`.
ddiff --git a/server/objects/legacy-paste-retire.js b/server/objects/legacy-paste-retire.js
iindex 0faa01e..ed2e24c 100644
---- a/server/objects/legacy-paste-retire.js
++++ b/server/objects/legacy-paste-retire.js
@@@ -2,19 +2,24 @@
/**
* POST /internal/community/legacy-paste-screenshots/:id/retire (OpenVibe.Community only)
*
-- * The narrow door for one inherited object that Community's move script cannot reach through its own
-- * tenant: a screenshot paste imported from Live has its object in the `live` tenant with
-- * `legacy_ref = legacy:live:paste:<slug>` (docs/object-model.md, "Ids and legacy refs"). DELETE
-- * /api/v2/community/objects/<id> answers 404 for it (another tenant), and Media's direct v2 delete
-- * refuses every legacy object with 409 media.object.legacy_managed. Community re-hosted three such
-- * pastes privately in the 2026-11-06 pilot before that was known, so their old public objects need a
-- * way to be retired without widening the v2 delete for every legacy row.
++ * The narrow door for the two inherited screenshot objects that Community's move script cannot
++ * delete through its own tenant (docs/object-model.md, "Ids and legacy refs"):
*
-- * Exactly that shape is retirable: app `live`, kind `screenshot`, legacy ref `legacy:live:paste:<slug>`.
-- * VODs, clips, files, thumbnails and avatars keep their own routes and are refused here, as is a
-- * native object (no legacy_ref). Only the Community service principal (`svc:community`) may call it,
-- * with media.object.delete, on loopback, through the same service-token guard as Media's other
-- * internal routes; another service holding the capability is refused.
++ * - a screenshot paste imported from Live has its object in the `live` tenant with
++ * `legacy_ref = legacy:live:paste:<slug>`. DELETE /api/v2/community/objects/<id> answers 404
++ * for it (another tenant).
++ * - an older paste stored its bytes through Community's v1 file store: the object is in the
++ * `community` tenant, kind `file`, with `legacy_ref = legacy:community:file:<key>`. DELETE finds
++ * it but the direct v2 delete refuses every legacy object with 409 media.object.legacy_managed.
++ *
++ * Community re-hosted those pastes privately in the 2026-11-06 pilot and needs to retire the old
++ * public bytes without widening the v2 delete for every legacy row.
++ *
++ * Exactly those two shapes are retirable. VODs, clips, thumbnails, avatars, other legacy file
++ * references and native objects (no legacy_ref) keep their own routes and are refused here. Only
++ * the Community service principal (`svc:community`) may call it, with media.object.delete, on
++ * loopback, through the same service-token guard as Media's other internal routes; another service
++ * holding the capability is refused.
*
* The delete is the ordinary soft delete (model.softDelete): /o/:id answers 410 at once, the bytes
* stay for MEDIA_DELETE_RETENTION_DAYS (metadata.retention_until), the media_objects trigger queues
@@@ -34,11 +39,17 @@ function problem(res, status, code, detail) {
return http.sendProblem(res, status, code, { detail });
}
--/** The one shape this route may retire (an imported Live screenshot paste, never an avatar or a VOD). */
++/**
++ * The two shapes this route may retire: an imported Live screenshot paste, or a Community-era v1
++ * file that held a screenshot paste's bytes (never an avatar, VOD or clip).
++ */
function isRetirable(obj) {
-- if (!obj || obj.app_id !== 'live' || obj.kind !== 'screenshot') return false;
++ if (!obj) return false;
const ref = model.parseLegacyRef(obj.legacy_ref);
-- return !!ref && ref.app === 'live' && ref.kind === 'paste' && !!ref.id;
++ if (!ref || !ref.id) return false;
++ if (obj.app_id === 'live' && obj.kind === 'screenshot') return ref.app === 'live' && ref.kind === 'paste';
++ if (obj.app_id === 'community' && obj.kind === 'file') return ref.app === 'community' && ref.kind === 'file';
++ return false;
}
async function retire(req, res) {
@@@ -51,7 +62,7 @@ async function retire(req, res) {
if (!obj) return problem(res, 404, 'media.object.not_found', 'No such object');
if (!isRetirable(obj)) {
return problem(res, 409, 'media.object.legacy_managed',
-- 'Only a legacy Live paste screenshot is retired here; VODs, clips, files, thumbnails and avatars keep their own routes');
++ 'Only a legacy Live paste screenshot or a legacy Community file is retired here; VODs, clips, thumbnails, avatars and native objects keep their own routes');
}
if (obj.lifecycle_status === 'deleted') {
const md = model.parseJson(obj.metadata, {});
ddiff --git a/test/legacy-paste-retire.test.js b/test/legacy-paste-retire.test.js
iindex c547322..ca072fe 100644
---- a/test/legacy-paste-retire.test.js
++++ b/test/legacy-paste-retire.test.js
@@@ -109,6 +109,24 @@ const crypto = require('crypto');
assert.strictEqual(await stagedOf(paste), 0, 'nothing left staged');
console.log('✅ a second call is a no-op (changed:false, no event)');
++ // ── A Community-era v1 file (a screenshot paste's old bytes) is retired too ──
++ const file = await insert({ appId: 'community', kind: 'file', ref: 'legacy:community:file:old-shot.jpg' });
++ r = await call(file, community);
++ assert.deepStrictEqual([r.status, r.body.changed, r.body.lifecycle_status], [200, true, 'deleted'], JSON.stringify(r.body));
++ assert.ok(Date.parse(r.body.retention_until) > Date.now(), 'the ordinary retention window is set');
++ const goneFile = await row(file);
++ assert.strictEqual(goneFile.lifecycle_status, 'deleted');
++ assert.strictEqual(JSON.parse(goneFile.metadata).deleted_by, 'svc:community');
++ const servedFile = await fetch(`${base}/o/${file}`);
++ assert.strictEqual(servedFile.status, 410, '/o/:id answers 410 at once');
++ const fileEvs = await eventsOf(file);
++ assert.strictEqual(fileEvs.length, 1, 'one media.object.deleted event, like any other delete');
++ assert.deepStrictEqual([fileEvs[0].payload.kind, fileEvs[0].payload.legacy_ref], ['file', 'legacy:community:file:old-shot.jpg']);
++ r = await call(file, community);
++ assert.deepStrictEqual([r.status, r.body.changed], [200, false], 'a second call is a no-op');
++ assert.strictEqual((await eventsOf(file)).length, 1, 'no second event');
++ console.log('✅ a legacy Community file is soft-deleted: 410, retention, one event, idempotent');
++
// ── Another kind is refused, never touched ──
for (const [kind, ref] of [['avatar', 'legacy:live:avatar:old-avatar'], ['vod', 'legacy:live:vod:7'], ['clip', 'legacy:live:clip:9'], ['file', 'legacy:live:file:k']]) {
const other = await insert({ kind, ref });
@@@ -130,18 +148,23 @@ const crypto = require('crypto');
assert.strictEqual((await row(second)).lifecycle_status, 'ready');
console.log('✅ only svc:community with media.object.delete may retire');
-- // ── Unknown id, another tenant's paste, and a hold ──
++ // ── Unknown id, a legacy shape this route does not own, and a hold ──
r = await call(ids.newId('media'), community);
assert.deepStrictEqual([r.status, r.body.code], [404, 'media.object.not_found']);
const foreign = await insert({ appId: 'community', ref: 'legacy:community:paste:foreign' });
r = await call(foreign, community);
-- assert.deepStrictEqual([r.status, r.body.code], [409, 'media.object.legacy_managed'], 'only the live tenant');
++ assert.deepStrictEqual([r.status, r.body.code], [409, 'media.object.legacy_managed'], 'only the two retirable shapes');
const held = await insert({ ref: 'legacy:live:paste:held' });
await db.run("INSERT INTO media_holds (object_id, kind, reason, created_by) VALUES (?, 'dmca', 'test', 'staff:test')", [held]);
r = await call(held, community);
assert.deepStrictEqual([r.status, r.body.code], [409, 'media.object.held'], JSON.stringify(r.body));
assert.strictEqual((await row(held)).lifecycle_status, 'ready');
-- console.log('✅ unknown ids 404, another tenant is refused, a hold refuses the retire');
++ const heldFile = await insert({ appId: 'community', kind: 'file', ref: 'legacy:community:file:held' });
++ await db.run("INSERT INTO media_holds (object_id, kind, reason, created_by) VALUES (?, 'dmca', 'test', 'staff:test')", [heldFile]);
++ r = await call(heldFile, community);
++ assert.deepStrictEqual([r.status, r.body.code], [409, 'media.object.held'], JSON.stringify(r.body));
++ assert.strictEqual((await row(heldFile)).lifecycle_status, 'ready');
++ console.log('✅ unknown ids 404, other legacy shapes are refused, a hold refuses either retire');
// ── Rate-limited like DELETE /:id ──
const limit = actorLimits.registered().find((l) => l.id === 'media.object.legacy_paste.retire');