openvibers/openvibe.media Commits
Media: retire legacy Community v1 files for the screenshot move (#325)
Changes
@@@ -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 repeatis 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 andretention purge answer 409 held; an operator's overruling category releases it with who and when. Adds Vision'slogged 60-second `operator-view` and service-only `review-decision` routes, a separate operator signature purpose,@@@ -60,9 +60,10 @@ the transitional `media.media-ref@1` legacy form in `legacy_ref`, which is uniquLegacy 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 narrowexception 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`.@@@ -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, {});@@@ -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');
@@ -13,6 +13,12 @@
@@ -13,6 +13,12 @@
(`media.object.delete`, principal `svc:community`) and soft-deletes exactly a `legacy:live:paste:<slug>`
(`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
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.
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
- 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
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,
logged 60-second `operator-view` and service-only `review-decision` routes, a separate operator signature purpose,
@@ -60,9 +60,10 @@ the transitional `media.media-ref@1` legacy form in `legacy_ref`, which is uniqu
@@ -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
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
(`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
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`.
Every v2 route that takes `:id` accepts either form. Native v2 objects have `legacy_ref = NULL`.
@@ -2,19 +2,24 @@
@@ -2,19 +2,24 @@
/**
/**
* POST /internal/community/legacy-paste-screenshots/:id/retire (OpenVibe.Community only)
* 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
* 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
* stay for MEDIA_DELETE_RETENTION_DAYS (metadata.retention_until), the media_objects trigger queues
@@ -34,11 +39,17 @@ function problem(res, status, code, detail) {
@@ -34,11 +39,17 @@ function problem(res, status, code, detail) {
return http.sendProblem(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) {
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);
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) {
async function retire(req, res) {
@@ -51,7 +62,7 @@ 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 (!obj) return problem(res, 404, 'media.object.not_found', 'No such object');
if (!isRetirable(obj)) {
if (!isRetirable(obj)) {
return problem(res, 409, 'media.object.legacy_managed',
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') {
if (obj.lifecycle_status === 'deleted') {
const md = model.parseJson(obj.metadata, {});
const md = model.parseJson(obj.metadata, {});
@@ -109,6 +109,24 @@ const crypto = require('crypto');
@@ -109,6 +109,24 @@ const crypto = require('crypto');
assert.strictEqual(await stagedOf(paste), 0, 'nothing left staged');
assert.strictEqual(await stagedOf(paste), 0, 'nothing left staged');
console.log('✅ a second call is a no-op (changed:false, no event)');
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 ──
// ── 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']]) {
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 });
const other = await insert({ kind, ref });
@@ -130,18 +148,23 @@ const crypto = require('crypto');
@@ -130,18 +148,23 @@ const crypto = require('crypto');
assert.strictEqual((await row(second)).lifecycle_status, 'ready');
assert.strictEqual((await row(second)).lifecycle_status, 'ready');
console.log('✅ only svc:community with media.object.delete may retire');
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);
r = await call(ids.newId('media'), community);
assert.deepStrictEqual([r.status, r.body.code], [404, 'media.object.not_found']);
assert.deepStrictEqual([r.status, r.body.code], [404, 'media.object.not_found']);
const foreign = await insert({ appId: 'community', ref: 'legacy:community:paste:foreign' });
const foreign = await insert({ appId: 'community', ref: 'legacy:community:paste:foreign' });
r = await call(foreign, community);
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' });
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]);
await db.run("INSERT INTO media_holds (object_id, kind, reason, created_by) VALUES (?, 'dmca', 'test', 'staff:test')", [held]);
r = await call(held, community);
r = await call(held, community);
assert.deepStrictEqual([r.status, r.body.code], [409, 'media.object.held'], JSON.stringify(r.body));
assert.deepStrictEqual([r.status, r.body.code], [409, 'media.object.held'], JSON.stringify(r.body));
assert.strictEqual((await row(held)).lifecycle_status, 'ready');
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 ──
// ── Rate-limited like DELETE /:id ──
const limit = actorLimits.registered().find((l) => l.id === 'media.object.legacy_paste.retire');
const limit = actorLimits.registered().find((l) => l.id === 'media.object.legacy_paste.retire');