openvibers/openvibe.media Commits
Media: a narrow route to retire a legacy Live paste screenshot (#322)
Changes
@@@ -2,6 +2,12 @@## Unreleased++- A legacy Live paste screenshot can be retired for OpenVibe.Community (2026-11-06). The internal++ `POST /internal/community/legacy-paste-screenshots/:id/retire` route is loopback and service-token only++ (`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.++- A tenant for the screenshot pass (plan L9, devblog §3.3): OpenVibe.Host stores the PNGs it captures (ovhost shots) in its owntenant, `host`, through the Object API v2. The tenant is created at boot like the other first-party ones, and reached onlywith a Network service token that holds the media.object.* verbs for the host namespace.@@@ -58,7 +58,11 @@ the transitional `media.media-ref@1` legacy form in `legacy_ref`, which is uniqu| `vods`/`clips.thumbnail_url` (`/t/<name>`) | thumbnail | `legacy:<app>:thumbnail:<name>` |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.++(`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`).Every v2 route that takes `:id` accepts either form. Native v2 objects have `legacy_ref = NULL`.@@@ -165,6 +165,13 @@ const ready = (async () => {app.post('/internal/avatar-ingest', require('./service-guard').guard('media.avatar.ingest'),require('./avatars/ingest').createIngestHandler({ db: require('./db/database'), config }));}++ // ── Internal: retiring one legacy Live paste screenshot for OpenVibe.Community (see++ // server/objects/legacy-paste-retire.js). Same service-token guard and loopback rule as above;++ // the capability is media.object.delete and the handler refuses every other principal. ──++ {++ const retire = require('./objects/legacy-paste-retire');++ app.post(retire.PATH, require('./service-guard').guard(retire.CAPABILITY), ...retire.handlers());++ }const userAuth = require('./user-auth');const userAuthConfig = {baseUrl: config.publicUrl, networkUrl: config.network.url, networkInternalUrl: config.network.internalUrl,nnew file mode 100644@@@ -0,0 +1,81 @@++'use strict';++/**++ * 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.++ *++ * 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.++ *++ * 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++ * the same media.object.deleted event as any other delete, and a retention hold refuses it. Calling++ * it again is a no-op: the object is already deleted, so the answer is changed:false and no second++ * event is queued. It is rate-limited like DELETE /:id.++ */++const { http } = require('openvibe-contracts');++const model = require('./model');++const { limits } = require('../actor-limits');++++const PATH = '/internal/community/legacy-paste-screenshots/:id/retire';++const CAPABILITY = 'media.object.delete';++const PRINCIPAL = 'svc:community';++++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). */++function isRetirable(obj) {++ if (!obj || obj.app_id !== 'live' || obj.kind !== 'screenshot') return false;++ const ref = model.parseLegacyRef(obj.legacy_ref);++ return !!ref && ref.app === 'live' && ref.kind === 'paste' && !!ref.id;++}++++async function retire(req, res) {++ if (!req.principal || req.principal.sub !== PRINCIPAL) {++ return problem(res, 403, 'capability.denied', `${PATH} is for OpenVibe.Community only`);++ }++ const raw = String(req.params.id || '').trim();++ if (!raw) return problem(res, 404, 'media.object.not_found', 'No such object');++ const obj = raw.startsWith('legacy:') ? await model.getObjectByLegacyRef(raw) : await model.getObject(raw);++ 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');++ }++ if (obj.lifecycle_status === 'deleted') {++ const md = model.parseJson(obj.metadata, {});++ return res.json({ id: obj.id, lifecycle_status: 'deleted', changed: false, ...(md.retention_until ? { retention_until: md.retention_until } : {}) });++ }++ if (await model.isHeld(obj.id)) return problem(res, 409, 'media.object.held', 'Object is under a retention hold');++ try {++ const gone = await model.softDelete(obj, { by: PRINCIPAL });++ const md = model.parseJson(gone && gone.metadata, {});++ res.json({ id: obj.id, lifecycle_status: 'deleted', changed: true, ...(md.retention_until ? { retention_until: md.retention_until } : {}) });++ } catch (err) {++ if (err && (err.code === 'media.object.held' || /retention hold/.test(err.message))) {++ return problem(res, 409, 'media.object.held', 'Object is under a retention hold');++ }++ console.error('[Objects] legacy paste retire error:', err.message);++ problem(res, 500, 'media.object.delete_failed', 'Failed to retire object');++ }++}++++// Deletes change lifecycle, quota and events: the same tighter budget the v2 DELETE uses.++const limit = limits('media.object.legacy_paste.retire', { minute: 60, hour: 1200 });++++function handlers() {++ return [limit, retire];++}++++module.exports = { PATH, CAPABILITY, PRINCIPAL, isRetirable, limit, handlers };nnew file mode 100644@@@ -0,0 +1,156 @@++'use strict';++// POST /internal/community/legacy-paste-screenshots/:id/retire (server/objects/legacy-paste-retire.js):++// the narrow legacy-retire door for OpenVibe.Community. Only a legacy:live:paste:<slug> screenshot is++// retired, only for svc:community with media.object.delete; the delete is the ordinary soft delete++// (410 at /o/:id, retention_until, one media.object.deleted event) and a repeat call is a no-op.++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++const crypto = require('crypto');++++(async () => {++ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-legacy-retire-'));++ process.env.OBJECTS_PATH = path.join(tmp, 'objects');++ fs.mkdirSync(process.env.OBJECTS_PATH, { recursive: true });++ process.env.OV_NETWORK_URL = 'https://openvibe.network';++ process.env.MEDIA_PUBLIC_URL = 'https://media.test';++++ // A stub Network/Events: token redemption for the outbox, every delivery refused so the queued++ // event stays in event_outbox for the assertion.++ const stub = http.createServer((req, res) => {++ req.resume();++ req.on('end', () => {++ res.setHeader('Content-Type', 'application/json');++ if (req.url === '/oauth/token') return res.end(JSON.stringify({ access_token: 'tok', token_type: 'Bearer', expires_in: 300 }));++ res.statusCode = 503;++ res.end('{}');++ });++ });++ await new Promise((r) => stub.listen(0, '127.0.0.1', r));++ const stubBase = `http://127.0.0.1:${stub.address().port}`;++++ const express = require('express');++ const db = require('../server/db/database');++ const auth = require('../server/auth');++ const model = require('../server/objects/model');++ const objectRoutes = require('../server/objects/routes');++ const events = require('../server/events');++ const retire = require('../server/objects/legacy-paste-retire');++ const { guard } = require('../server/service-guard');++ const { serviceAuth, ids } = require('openvibe-contracts');++ const actorLimits = require('../server/actor-limits');++++ const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });++ auth._setNetworkPublicKeyForTests(keys.publicKey);++ await db.upsertApp({ app_id: 'live', api_key: 'live-key' });++ await db.upsertApp({ app_id: 'community', api_key: 'community-key' });++ events.init({ eventsUrl: stubBase, clientSecret: 's', networkUrl: stubBase, intervalMs: 3600000 });++++ const now = Math.floor(Date.now() / 1000);++ const tok = (sub, cap = [retire.CAPABILITY]) => serviceAuth.signServiceToken({++ iss: 'https://openvibe.network', sub, actor_type: 'service', aud: ['openvibe.media'], cap,++ ns: ['live'], iat: now, exp: now + 300, jti: `tok_${crypto.randomBytes(6).toString('hex')}`,++ }, keys.privateKey);++++ const app = express();++ app.use(express.json());++ app.post(retire.PATH, guard(retire.CAPABILITY), ...retire.handlers());++ app.use('/o', objectRoutes.publicRouter);++ const server = http.createServer(app);++ await new Promise((r) => server.listen(0, '127.0.0.1', r));++ const base = `http://127.0.0.1:${server.address().port}`;++ const call = async (id, bearer) => {++ const res = await fetch(`${base}/internal/community/legacy-paste-screenshots/${encodeURIComponent(id)}/retire`, {++ method: 'POST', headers: bearer ? { authorization: `Bearer ${bearer}` } : {},++ });++ const text = await res.text();++ let body = null; try { body = JSON.parse(text); } catch { /* not json */ }++ return { status: res.status, body };++ };++ const insert = async ({ appId = 'live', kind = 'screenshot', ref, visibility = 'public', lifecycle = 'ready' } = {}) => {++ const id = ids.newId('media');++ await db.run('INSERT INTO media_objects (id, app_id, namespace, kind, visibility, lifecycle_status, legacy_ref) VALUES (?, ?, ?, ?, ?, ?, ?)',++ [id, appId, appId, kind, visibility, lifecycle, ref || null]);++ return id;++ };++ const row = async (id) => await db.get('SELECT * FROM media_objects WHERE id = ?', [id]);++ const eventsOf = async (id) => (await db.all("SELECT envelope FROM event_outbox WHERE envelope::text LIKE ?", [`%${id}%`]))++ .map((r) => (typeof r.envelope === 'string' ? JSON.parse(r.envelope) : r.envelope))++ .filter((e) => e.event_type === 'media.object.deleted' && e.payload.object_id === id);++ const stagedOf = async (id) => Number((await db.get('SELECT COUNT(*) AS n FROM media_object_changes WHERE object_id = ?', [id])).n);++++ try {++ const community = tok('svc:community');++ const live = tok('svc:live');++++ // ── The right kind is retired: soft delete, 410, retention and one event ──++ const paste = await insert({ ref: 'legacy:live:paste:old-shot' });++ let r = await call(paste, 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 gone = await row(paste);++ assert.strictEqual(gone.lifecycle_status, 'deleted');++ assert.ok(gone.deleted_at, 'deleted_at is set');++ assert.strictEqual(JSON.parse(gone.metadata).deleted_by, 'svc:community');++ const served = await fetch(`${base}/o/${paste}`);++ assert.strictEqual(served.status, 410, '/o/:id answers 410 at once');++ let evs = await eventsOf(paste);++ assert.strictEqual(evs.length, 1, 'one media.object.deleted event, like any other delete');++ assert.deepStrictEqual([evs[0].payload.kind, evs[0].payload.legacy_ref], ['screenshot', 'legacy:live:paste:old-shot']);++ assert.ok(Date.parse(evs[0].payload.deleted_at), 'the event carries the change time');++ console.log('✅ a legacy Live paste screenshot is soft-deleted: 410, retention, one media.object.deleted');++++ // ── Idempotent: the second call changes nothing and queues no second event ──++ r = await call(paste, community);++ assert.deepStrictEqual([r.status, r.body.changed, r.body.lifecycle_status], [200, false, 'deleted'], JSON.stringify(r.body));++ assert.strictEqual((await eventsOf(paste)).length, 1, 'no second event');++ assert.strictEqual(await stagedOf(paste), 0, 'nothing left staged');++ console.log('✅ a second call is a no-op (changed:false, no event)');++++ // ── 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 });++ r = await call(other, community);++ assert.deepStrictEqual([r.status, r.body.code], [409, 'media.object.legacy_managed'], `${kind}: ${JSON.stringify(r.body)}`);++ assert.strictEqual((await row(other)).lifecycle_status, 'ready', `${kind} stays ready`);++ }++ const native = await insert({ ref: null });++ r = await call(native, community);++ assert.deepStrictEqual([r.status, r.body.code], [409, 'media.object.legacy_managed'], 'a native screenshot is not a legacy retire');++ console.log('✅ avatars, VODs, clips, files and native objects are refused');++++ // ── Another principal is refused, even with the capability ──++ const second = await insert({ ref: 'legacy:live:paste:other-principal' });++ r = await call(second, live);++ assert.deepStrictEqual([r.status, r.body.code], [403, 'capability.denied'], JSON.stringify(r.body));++ r = await call(second, tok('svc:community', ['media.object.upload']));++ assert.deepStrictEqual([r.status, r.body.code], [403, 'capability.denied'], 'the capability is still required');++ 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 ──++ 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');++ 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');++++ // ── Rate-limited like DELETE /:id ──++ const limit = actorLimits.registered().find((l) => l.id === 'media.object.legacy_paste.retire');++ assert.deepStrictEqual([limit && limit.minute, limit && limit.hour], [60, 1200]);++ console.log('✅ the route declares the DELETE /:id budget');++ } finally {++ events._reset();++ await new Promise((r) => server.close(r));++ await new Promise((r) => stub.close(r));++ }++ console.log('legacy paste retire: all checks passed');++})().catch((err) => { console.error(err); process.exit(1); });
@@ -2,6 +2,12 @@
@@ -2,6 +2,12 @@
## Unreleased
## Unreleased
+- A legacy Live paste screenshot can be retired for OpenVibe.Community (2026-11-06). The internal
+ `POST /internal/community/legacy-paste-screenshots/:id/retire` route is loopback and service-token only
+ (`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.
+
- A tenant for the screenshot pass (plan L9, devblog §3.3): OpenVibe.Host stores the PNGs it captures (ovhost shots) in its own
- A tenant for the screenshot pass (plan L9, devblog §3.3): OpenVibe.Host stores the PNGs it captures (ovhost shots) in its own
tenant, `host`, through the Object API v2. The tenant is created at boot like the other first-party ones, and reached only
tenant, `host`, through the Object API v2. The tenant is created at boot like the other first-party ones, and reached only
with a Network service token that holds the media.object.* verbs for the host namespace.
with a Network service token that holds the media.object.* verbs for the host namespace.
@@ -58,7 +58,11 @@ the transitional `media.media-ref@1` legacy form in `legacy_ref`, which is uniqu
@@ -58,7 +58,11 @@ the transitional `media.media-ref@1` legacy form in `legacy_ref`, which is uniqu
| `vods`/`clips.thumbnail_url` (`/t/<name>`) | thumbnail | `legacy:<app>:thumbnail:<name>` |
| `vods`/`clips.thumbnail_url` (`/t/<name>`) | thumbnail | `legacy:<app>:thumbnail:<name>` |
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.
+(`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`).
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`.
@@ -165,6 +165,13 @@ const ready = (async () => {
@@ -165,6 +165,13 @@ const ready = (async () => {
app.post('/internal/avatar-ingest', require('./service-guard').guard('media.avatar.ingest'),
app.post('/internal/avatar-ingest', require('./service-guard').guard('media.avatar.ingest'),
require('./avatars/ingest').createIngestHandler({ db: require('./db/database'), config }));
require('./avatars/ingest').createIngestHandler({ db: require('./db/database'), config }));
}
}
+ // ── Internal: retiring one legacy Live paste screenshot for OpenVibe.Community (see
+ // server/objects/legacy-paste-retire.js). Same service-token guard and loopback rule as above;
+ // the capability is media.object.delete and the handler refuses every other principal. ──
+ {
+ const retire = require('./objects/legacy-paste-retire');
+ app.post(retire.PATH, require('./service-guard').guard(retire.CAPABILITY), ...retire.handlers());
+ }
const userAuth = require('./user-auth');
const userAuth = require('./user-auth');
const userAuthConfig = {
const userAuthConfig = {
baseUrl: config.publicUrl, networkUrl: config.network.url, networkInternalUrl: config.network.internalUrl,
baseUrl: config.publicUrl, networkUrl: config.network.url, networkInternalUrl: config.network.internalUrl,
new file mode 100644
new file mode 100644
@@ -0,0 +1,81 @@
@@ -0,0 +1,81 @@
+'use strict';
+/**
+ * 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.
+ *
+ * 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.
+ *
+ * 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
+ * the same media.object.deleted event as any other delete, and a retention hold refuses it. Calling
+ * it again is a no-op: the object is already deleted, so the answer is changed:false and no second
+ * event is queued. It is rate-limited like DELETE /:id.
+ */
+const { http } = require('openvibe-contracts');
+const model = require('./model');
+const { limits } = require('../actor-limits');
+
+const PATH = '/internal/community/legacy-paste-screenshots/:id/retire';
+const CAPABILITY = 'media.object.delete';
+const PRINCIPAL = 'svc:community';
+
+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). */
+function isRetirable(obj) {
+ if (!obj || obj.app_id !== 'live' || obj.kind !== 'screenshot') return false;
+ const ref = model.parseLegacyRef(obj.legacy_ref);
+ return !!ref && ref.app === 'live' && ref.kind === 'paste' && !!ref.id;
+}
+
+async function retire(req, res) {
+ if (!req.principal || req.principal.sub !== PRINCIPAL) {
+ return problem(res, 403, 'capability.denied', `${PATH} is for OpenVibe.Community only`);
+ }
+ const raw = String(req.params.id || '').trim();
+ if (!raw) return problem(res, 404, 'media.object.not_found', 'No such object');
+ const obj = raw.startsWith('legacy:') ? await model.getObjectByLegacyRef(raw) : await model.getObject(raw);
+ 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');
+ }
+ if (obj.lifecycle_status === 'deleted') {
+ const md = model.parseJson(obj.metadata, {});
+ return res.json({ id: obj.id, lifecycle_status: 'deleted', changed: false, ...(md.retention_until ? { retention_until: md.retention_until } : {}) });
+ }
+ if (await model.isHeld(obj.id)) return problem(res, 409, 'media.object.held', 'Object is under a retention hold');
+ try {
+ const gone = await model.softDelete(obj, { by: PRINCIPAL });
+ const md = model.parseJson(gone && gone.metadata, {});
+ res.json({ id: obj.id, lifecycle_status: 'deleted', changed: true, ...(md.retention_until ? { retention_until: md.retention_until } : {}) });
+ } catch (err) {
+ if (err && (err.code === 'media.object.held' || /retention hold/.test(err.message))) {
+ return problem(res, 409, 'media.object.held', 'Object is under a retention hold');
+ }
+ console.error('[Objects] legacy paste retire error:', err.message);
+ problem(res, 500, 'media.object.delete_failed', 'Failed to retire object');
+ }
+}
+
+// Deletes change lifecycle, quota and events: the same tighter budget the v2 DELETE uses.
+const limit = limits('media.object.legacy_paste.retire', { minute: 60, hour: 1200 });
+
+function handlers() {
+ return [limit, retire];
+}
+
+module.exports = { PATH, CAPABILITY, PRINCIPAL, isRetirable, limit, handlers };
new file mode 100644
new file mode 100644
@@ -0,0 +1,156 @@
@@ -0,0 +1,156 @@
+'use strict';
+// POST /internal/community/legacy-paste-screenshots/:id/retire (server/objects/legacy-paste-retire.js):
+// the narrow legacy-retire door for OpenVibe.Community. Only a legacy:live:paste:<slug> screenshot is
+// retired, only for svc:community with media.object.delete; the delete is the ordinary soft delete
+// (410 at /o/:id, retention_until, one media.object.deleted event) and a repeat call is a no-op.
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+const crypto = require('crypto');
+
+(async () => {
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-legacy-retire-'));
+ process.env.OBJECTS_PATH = path.join(tmp, 'objects');
+ fs.mkdirSync(process.env.OBJECTS_PATH, { recursive: true });
+ process.env.OV_NETWORK_URL = 'https://openvibe.network';
+ process.env.MEDIA_PUBLIC_URL = 'https://media.test';
+
+ // A stub Network/Events: token redemption for the outbox, every delivery refused so the queued
+ // event stays in event_outbox for the assertion.
+ const stub = http.createServer((req, res) => {
+ req.resume();
+ req.on('end', () => {
+ res.setHeader('Content-Type', 'application/json');
+ if (req.url === '/oauth/token') return res.end(JSON.stringify({ access_token: 'tok', token_type: 'Bearer', expires_in: 300 }));
+ res.statusCode = 503;
+ res.end('{}');
+ });
+ });
+ await new Promise((r) => stub.listen(0, '127.0.0.1', r));
+ const stubBase = `http://127.0.0.1:${stub.address().port}`;
+
+ const express = require('express');
+ const db = require('../server/db/database');
+ const auth = require('../server/auth');
+ const model = require('../server/objects/model');
+ const objectRoutes = require('../server/objects/routes');
+ const events = require('../server/events');
+ const retire = require('../server/objects/legacy-paste-retire');
+ const { guard } = require('../server/service-guard');
+ const { serviceAuth, ids } = require('openvibe-contracts');
+ const actorLimits = require('../server/actor-limits');
+
+ const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
+ auth._setNetworkPublicKeyForTests(keys.publicKey);
+ await db.upsertApp({ app_id: 'live', api_key: 'live-key' });
+ await db.upsertApp({ app_id: 'community', api_key: 'community-key' });
+ events.init({ eventsUrl: stubBase, clientSecret: 's', networkUrl: stubBase, intervalMs: 3600000 });
+
+ const now = Math.floor(Date.now() / 1000);
+ const tok = (sub, cap = [retire.CAPABILITY]) => serviceAuth.signServiceToken({
+ iss: 'https://openvibe.network', sub, actor_type: 'service', aud: ['openvibe.media'], cap,
+ ns: ['live'], iat: now, exp: now + 300, jti: `tok_${crypto.randomBytes(6).toString('hex')}`,
+ }, keys.privateKey);
+
+ const app = express();
+ app.use(express.json());
+ app.post(retire.PATH, guard(retire.CAPABILITY), ...retire.handlers());
+ app.use('/o', objectRoutes.publicRouter);
+ const server = http.createServer(app);
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const call = async (id, bearer) => {
+ const res = await fetch(`${base}/internal/community/legacy-paste-screenshots/${encodeURIComponent(id)}/retire`, {
+ method: 'POST', headers: bearer ? { authorization: `Bearer ${bearer}` } : {},
+ });
+ const text = await res.text();
+ let body = null; try { body = JSON.parse(text); } catch { /* not json */ }
+ return { status: res.status, body };
+ };
+ const insert = async ({ appId = 'live', kind = 'screenshot', ref, visibility = 'public', lifecycle = 'ready' } = {}) => {
+ const id = ids.newId('media');
+ await db.run('INSERT INTO media_objects (id, app_id, namespace, kind, visibility, lifecycle_status, legacy_ref) VALUES (?, ?, ?, ?, ?, ?, ?)',
+ [id, appId, appId, kind, visibility, lifecycle, ref || null]);
+ return id;
+ };
+ const row = async (id) => await db.get('SELECT * FROM media_objects WHERE id = ?', [id]);
+ const eventsOf = async (id) => (await db.all("SELECT envelope FROM event_outbox WHERE envelope::text LIKE ?", [`%${id}%`]))
+ .map((r) => (typeof r.envelope === 'string' ? JSON.parse(r.envelope) : r.envelope))
+ .filter((e) => e.event_type === 'media.object.deleted' && e.payload.object_id === id);
+ const stagedOf = async (id) => Number((await db.get('SELECT COUNT(*) AS n FROM media_object_changes WHERE object_id = ?', [id])).n);
+
+ try {
+ const community = tok('svc:community');
+ const live = tok('svc:live');
+
+ // ── The right kind is retired: soft delete, 410, retention and one event ──
+ const paste = await insert({ ref: 'legacy:live:paste:old-shot' });
+ let r = await call(paste, 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 gone = await row(paste);
+ assert.strictEqual(gone.lifecycle_status, 'deleted');
+ assert.ok(gone.deleted_at, 'deleted_at is set');
+ assert.strictEqual(JSON.parse(gone.metadata).deleted_by, 'svc:community');
+ const served = await fetch(`${base}/o/${paste}`);
+ assert.strictEqual(served.status, 410, '/o/:id answers 410 at once');
+ let evs = await eventsOf(paste);
+ assert.strictEqual(evs.length, 1, 'one media.object.deleted event, like any other delete');
+ assert.deepStrictEqual([evs[0].payload.kind, evs[0].payload.legacy_ref], ['screenshot', 'legacy:live:paste:old-shot']);
+ assert.ok(Date.parse(evs[0].payload.deleted_at), 'the event carries the change time');
+ console.log('✅ a legacy Live paste screenshot is soft-deleted: 410, retention, one media.object.deleted');
+
+ // ── Idempotent: the second call changes nothing and queues no second event ──
+ r = await call(paste, community);
+ assert.deepStrictEqual([r.status, r.body.changed, r.body.lifecycle_status], [200, false, 'deleted'], JSON.stringify(r.body));
+ assert.strictEqual((await eventsOf(paste)).length, 1, 'no second event');
+ assert.strictEqual(await stagedOf(paste), 0, 'nothing left staged');
+ console.log('✅ a second call is a no-op (changed:false, no event)');
+
+ // ── 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 });
+ r = await call(other, community);
+ assert.deepStrictEqual([r.status, r.body.code], [409, 'media.object.legacy_managed'], `${kind}: ${JSON.stringify(r.body)}`);
+ assert.strictEqual((await row(other)).lifecycle_status, 'ready', `${kind} stays ready`);
+ }
+ const native = await insert({ ref: null });
+ r = await call(native, community);
+ assert.deepStrictEqual([r.status, r.body.code], [409, 'media.object.legacy_managed'], 'a native screenshot is not a legacy retire');
+ console.log('✅ avatars, VODs, clips, files and native objects are refused');
+
+ // ── Another principal is refused, even with the capability ──
+ const second = await insert({ ref: 'legacy:live:paste:other-principal' });
+ r = await call(second, live);
+ assert.deepStrictEqual([r.status, r.body.code], [403, 'capability.denied'], JSON.stringify(r.body));
+ r = await call(second, tok('svc:community', ['media.object.upload']));
+ assert.deepStrictEqual([r.status, r.body.code], [403, 'capability.denied'], 'the capability is still required');
+ 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 ──
+ 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');
+ 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');
+
+ // ── Rate-limited like DELETE /:id ──
+ const limit = actorLimits.registered().find((l) => l.id === 'media.object.legacy_paste.retire');
+ assert.deepStrictEqual([limit && limit.minute, limit && limit.hour], [60, 1200]);
+ console.log('✅ the route declares the DELETE /:id budget');
+ } finally {
+ events._reset();
+ await new Promise((r) => server.close(r));
+ await new Promise((r) => stub.close(r));
+ }
+ console.log('legacy paste retire: all checks passed');
+})().catch((err) => { console.error(err); process.exit(1); });