openvibers/openvibe.media Commits

Media: a narrow route to retire a legacy Live paste screenshot (#322)

OP OpenVibers committed 7 days ago · 8408a96f43 · 5 files, +255 −1

Community's move script cannot delete an inherited Live paste screenshot through its own tenant
(404) and the v2 delete refuses every legacy object (409). Add POST
/internal/community/legacy-paste-screenshots/:id/retire, loopback and service-token only, capability
media.object.delete, principal svc:community. It retires exactly kind=screenshot with
legacy_ref=legacy:live:paste:<slug>: an ordinary soft delete (410 at /o/:id, the retention window and
media.object.deleted event), idempotent and rate-limited like DELETE /:id. VODs, clips, files,
thumbnails, avatars, native objects and other principals are refused.

Tests: test/legacy-paste-retire.test.js (right kind retired, repeat no-op, other kinds/principal/tenant
refused, hold refused, event and limit); test/legacy-paste-retire, limits, object-events,
paste-screenshot-redirect and security-secrets pass.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex c67f0ca..785efdd 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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 own
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.
ddiff --git a/docs/object-model.md b/docs/object-model.md
iindex a4a7b22..15c541f 100644
---- a/docs/object-model.md
++++ b/docs/object-model.md
@@@ -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`.
ddiff --git a/server/index.js b/server/index.js
iindex 0014a09..7146667 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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,
ddiff --git a/server/objects/legacy-paste-retire.js b/server/objects/legacy-paste-retire.js
nnew file mode 100644
iindex 0000000..0faa01e
---- /dev/null
++++ b/server/objects/legacy-paste-retire.js
@@@ -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 };
ddiff --git a/test/legacy-paste-retire.test.js b/test/legacy-paste-retire.test.js
nnew file mode 100644
iindex 0000000..c547322
---- /dev/null
++++ b/test/legacy-paste-retire.test.js
@@@ -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); });