openvibers/openvibe.media Commits
Media: the control API — Services can delete a person's object, or an object of a project they administer (contracts 0.169) (#107)
Changes
@@@ -59,6 +59,7 @@ key ([Tenancy & auth](#tenancy--auth)).| Capability | Audience | Route | Notes ||---|---|---|---|| `media.resource.read` | first-party | `GET /api/v1/resources`, `GET /api/v1/resources/:ovrn` | the authority resource index ([Resource index](#resource-index-apiv1resources-first-party)). Active in openvibe-contracts since 0.108.0; the route enforces it (a Network service token holding it, loopback, audience `openvibe.media`) |++| `media.resource.control` | first-party | `POST /api/v1/resources/control` | `delete` a person's (or their project's) object: the soft delete, restorable until its retention ends ([Resource index](#resource-index-apiv1resources-first-party)). Active in openvibe-contracts since 0.169.0 |Called elsewhere, as the service principal `media` (the OAuth client `media`):@@@ -96,6 +97,8 @@ server/files/routes.js /api/v1/:app/filesadmin/routes.js /api/v1/:app/admin/storage (disk, tiers, buckets, bulk ops)registry/resource-index.js the authority resource index (/api/v1/resources; common.resource-summary@1, ADR-048)++ registry/resource-control.js the control API (POST /api/v1/resources/control; delete an object, ADR-048)++ registry/projects.js whether a person administers a project (Network's internal membership read)thumbnails/ thumbnail service + /api/v1/:app/thumbnailspublic/routes.js public /v /c /p /t /fme/ the object explorer: /me and /api/v2/me (a signed-in person's own objects, uploads,@@@ -123,7 +126,7 @@ server/storage.orphans.scan (monthly storage orphan report, service-wide, report only),storage.move.cleanup (the alert for placement moves whose delete keeps failing, service-wide)client-ip.js trust proxy = loopback; req.ip is the only client address--(openvibe-shared v4.1.3, openvibe-contracts v0.143.0, openvibe-sdk v0.44.0: pinned release tarballs, installed by npm)++(openvibe-shared v4.1.3, openvibe-contracts v0.169.0, openvibe-sdk v0.44.0: pinned release tarballs, installed by npm)scripts/smoke-test.sh end-to-end smoke test (boots a temp instance)scripts/reconcile-objects.js / object-invariant.js / no-good-copy-report.js object-model operator toolsscripts/media-jobs.js list jobs, run the size-invariant scan (dry run by default), approve/cancel proposals@@@ -404,6 +407,15 @@ its objects carry no `project_id` and no `ovrn`. The summary's `ovrn` is composeresource whose computed name equals the one asked for answers. `owner` is a `subject-ref` when the objectstores a `usr_` owner. `test/resource-index.test.js`.++**Control** (`media.resource.control`, plan T13 step 10): `POST /api/v1/resources/control` takes a++`common.resource-control-request@1` from OpenVibe.Services (same loopback service-token check, and the caller must++be a `svc:` service) and answers a `common.resource-control-result@1`. The one action is `delete`: the API's soft++delete (`restorable_until` in the result; restore through `POST /api/v1/objects/:id/restore`). A held object and a++legacy-managed one (a VOD, clip or file) are refused. The object's owner may act, and so may an admin or owner of its++project (asked of Network with `network.project.member.read`, `server/registry/projects.js`); anyone else gets++`resources.unknown_resource`. Dry runs; the first answer per (caller, project, key) is kept in `media_control_log` and++returned for a repeat. `test/resource-control.test.js`.++## Restore drills (`MEDIA_DRILL=1`)A restore drill starts a second Media from this checkout on 127.0.0.1:14100 with `MEDIA_DRILL=1` andnnew file mode 100644@@@ -0,0 +1,12 @@++-- phase: expand++-- ADR-048 control operations (plan T13 step 10, server/registry/resource-control.js): the first answer to each++-- (caller, project, idempotency_key), returned again for a repeat so a retried control request never applies twice.++CREATE TABLE media_control_log (++ caller text COLLATE "C" NOT NULL, -- svc:… that sent it++ project_id text COLLATE "C" NOT NULL,++ idempotency_key text COLLATE "C" NOT NULL,++ digest text COLLATE "C" NOT NULL, -- sha256 of the request without its delivery fields++ response jsonb NOT NULL, -- the common.resource-control-result@1 answered++ created_at bigint NOT NULL,++ PRIMARY KEY (caller, project_id, idempotency_key)++);@@@ -16,7 +16,7 @@"express": "^4.19.2","iovalkey": "^0.4.0","multer": "^1.4.5-lts.1",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.143.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.169.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.3","pg": "^8.23.0",@@@ -1847,9 +1847,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.143.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.143.0",-- "integrity": "sha512-Cdbf+9RoPAUBibpM2zXQvtsiKMugb55dKubTt8pFZvAmxsLtGvX+zaC++yL1vOSub8FX3+GgqcGtoJS6tklrUg==",++ "version": "0.169.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.169.0",++ "integrity": "sha512-Py2z8UaKGvLzG3atuIAnJKnRe74Te9cJRTaxqFq6m4fov6gaMXeS6rn7lH0tSIo0qBGD5trrT618ARXnklkp+A==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -20,7 +20,7 @@"express": "^4.19.2","iovalkey": "^0.4.0","multer": "^1.4.5-lts.1",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.143.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.169.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.3","pg": "^8.23.0",@@@ -220,6 +220,7 @@ const ready = (async () => {// Media owns — its media objects — as common.resource-summary@1, for OpenVibe.Services to fan out and merge.// Loopback and a Network service token, like the other first-party routes (server/service-guard.js); mounted// ahead of /api/v1/:app/… so /api/v1/resources is never read as a tenant named "resources".++ app.use('/api/v1/resources', require('./registry/resource-control').router({ guard: require('./service-guard').guard('media.resource.control') }));app.use('/api/v1/resources', require('./registry/resource-index').router({ guard: require('./service-guard').guard('media.resource.read') }));app.use('/api/v1/:app/views', require('./views/routes'));app.use('/api/v1/:app/vods', require('./vod/routes'));nnew file mode 100644@@@ -0,0 +1,40 @@++'use strict';++/**++ * Project delegation (ADR-003 amendment 2026-10-12): whether a person is an admin or owner of an unarchived project,++ * asked of Network (GET /internal/projects/:project/members/:subject, capability network.project.member.read) with++ * Media's own client-credentials token. The control API (server/registry/resource-control.js) uses it so a project's++ * admins can delete the objects its apps stored.++ *++ * await createProjects().isAdmin(projectId, subjectId) → true | false; throws when Network cannot answer++ */++const { serviceAuth } = require('openvibe-contracts');++const config = require('../config');++++const ADMIN_ROLES = new Set(['admin', 'owner']);++++function createProjects({++ networkUrl = config.network.internalUrl,++ clientId = process.env.OV_OAUTH_CLIENT_ID || 'media',++ clientSecret = process.env.OV_OAUTH_CLIENT_SECRET || '',++ fetchImpl = globalThis.fetch,++} = {}) {++ const base = String(networkUrl || '').replace(/\/+$/, '');++ const tokens = clientSecret ? serviceAuth.createTokenClient({++ tokenUrl: `${base}/oauth/token`, clientId, clientSecret, audience: 'openvibe.network', scope: 'network.project.member.read', fetchImpl,++ }) : null;++++ async function isAdmin(projectId, subjectId) {++ if (!tokens) throw new Error('set OV_OAUTH_CLIENT_SECRET so Media can read project roles from Network');++ const res = await fetchImpl(`${base}/internal/projects/${encodeURIComponent(projectId)}/members/${encodeURIComponent(subjectId)}`, {++ headers: { Accept: 'application/json', ...(await tokens.authHeaders()) }, signal: AbortSignal.timeout(10_000),++ });++ if (res.status === 404) return false;++ if (!res.ok) throw new Error(`Network answered ${res.status} for a project membership`);++ const out = await res.json().catch(() => null);++ return !!out && out.project_id === projectId && out.subject === subjectId && ADMIN_ROLES.has(out.role) && out.archived === false;++ }++++ return { isAdmin };++}++++module.exports = { createProjects, ADMIN_ROLES };nnew file mode 100644@@@ -0,0 +1,108 @@++'use strict';++/**++ * Media's control API (ADR-048, plan T13 step 10; capability media.resource.control):++ *++ * POST /api/v1/resources/control common.resource-control-request@1 → common.resource-control-result@1++ *++ * From a first-party service (OpenVibe.Services) on loopback, acting for the person in on_behalf_of, on a media.object++ * named by its OVRN. 200 for every outcome; a malformed request is a 400 problem.++ *++ * delete a soft delete, exactly as DELETE /api/v1/objects/:id: the object stops being served at once and can be++ * restored through the API until its retention period ends. An object under a retention hold, and a legacy++ * VOD, clip or file (managed through its own routes), is refused.++ *++ * Any other action is refused `control.unsupported_action`. The object's owner may act on it, and so may an admin or++ * owner of its project (asked of Network: server/registry/projects.js), which is how a project's admins delete what++ * its apps stored. Anyone else is refused as not found, never confirmed to exist; and only inside the project the OVRN++ * names. `dry_run` decides and explains without applying. The first answer per (caller, project, idempotency_key) is++ * kept in media_control_log and returned for a repeat; the same key with another request is refused++ * `control.idempotency_conflict`.++ */++const crypto = require('crypto');++const express = require('express');++const contracts = require('openvibe-contracts');++++const SERVICE = 'media';++const CAPABILITY = 'media.resource.control';++const USER_SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++const OBJECT_ID_RE = /^med_[0-9A-HJKMNP-TV-Z]{26}$/;++++function canonical(v) {++ if (Array.isArray(v)) return `[${v.map(canonical).join(',')}]`;++ if (v && typeof v === 'object') return `{${Object.keys(v).sort().filter((k) => v[k] !== undefined).map((k) => `${JSON.stringify(k)}:${canonical(v[k])}`).join(',')}}`;++ return JSON.stringify(v);++}++const digestOf = (request) => crypto.createHash('sha256')++ .update(canonical({ ...request, idempotency_key: undefined, confirmation_id: undefined, trace_id: undefined })).digest('hex');++++function router({ guard, projects = require('./projects').createProjects(), db = require('../db/database'), now = () => Date.now() }) {++ const r = express.Router();++ const json = express.json({ limit: '16kb' });++ const iso = () => new Date(now()).toISOString();++ const result = (request, state, extra = {}) => ({ action: request.action, ...(request.resource ? { resource: request.resource } : {}), state, at: iso(), ...extra });++ const problem = (status, code, detail) => ({ type: `https://openvibe.network/problems/${code}`, title: code, status, code, detail });++++ async function decide(request, caller) {++ const parsed = contracts.resources.parse(request.resource || '');++ if (!parsed || parsed.service !== SERVICE || parsed.type !== 'object' || !OBJECT_ID_RE.test(parsed.id)) {++ return result(request, 'refused', { problem: problem(404, 'resources.unknown_resource', `no object named ${request.resource || request.resource_kind}`) });++ }++ if (request.action !== 'delete') {++ return result(request, 'refused', { problem: problem(422, 'control.unsupported_action', `an object takes delete, not ${request.action}`) });++ }++ const person = request.on_behalf_of;++ if (!person || person.type !== 'user' || !USER_SUBJECT_RE.test(String(person.id || ''))) {++ return result(request, 'refused', { problem: problem(403, 'control.no_person', 'on_behalf_of must name the person acting') });++ }++ const obj = await db.get('SELECT o.*, a.project_id AS project FROM media_objects o LEFT JOIN apps a ON a.app_id = o.app_id WHERE o.id = ?', [parsed.id]);++ const inProject = !!obj && obj.project === parsed.project_id && parsed.project_id === request.project_id;++ let allowed = inProject && obj.owner_subject === person.id;++ if (inProject && !allowed) {++ try { allowed = await projects.isAdmin(obj.project, person.id); } catch {++ return result(request, 'failed', { problem: problem(503, 'identity.unavailable', 'Network could not say whether this person administers the project; nothing was changed') });++ }++ }++ if (!allowed) return result(request, 'refused', { problem: problem(404, 'resources.unknown_resource', `no object named ${request.resource} that this person may delete`) });++++ const model = require('../objects/model');++ if (obj.legacy_ref) return result(request, 'refused', { problem: problem(409, 'media.object.legacy_managed', 'a VOD, clip or file is deleted through its own routes') });++ if (obj.lifecycle_status === 'deleted') return result(request, 'done', { result: { status: 'deleted', changed: false } });++ if (await model.isHeld(obj.id)) return result(request, 'refused', { problem: problem(409, 'media.object.held', 'the object is under a retention hold') });++ if (request.dry_run) return result(request, 'done', { result: { dry_run: true, would: 'delete the object (restorable until its retention period ends)' } });++ let gone;++ try { gone = await model.softDelete(obj, { by: `user:${person.id} via ${caller}` }); } catch (err) {++ if (err && (err.code === 'media.object.held' || /retention hold/.test(err.message))) return result(request, 'refused', { problem: problem(409, 'media.object.held', 'the object is under a retention hold') });++ throw err;++ }++ const app = await db.getApp(obj.app_id);++ if (app) await require('../objects/namespaces').reconcileChain(app, obj.namespace);++ const md = model.parseJson(gone && gone.metadata, {});++ return result(request, 'done', { result: { status: 'deleted', changed: true, ...(md.retention_until ? { restorable_until: md.retention_until } : {}) } });++ }++++ r.post('/control', guard, json, async (req, res, next) => {++ try {++ res.set('Cache-Control', 'no-store');++ // First-party: the capability is for OpenVibe's own services, never an app's or agent's token.++ if (!String(req.principal.sub || '').startsWith('svc:')) return contracts.http.sendProblem(res, 403, 'capability.denied', { detail: `${CAPABILITY} requires a service token` });++ const request = req.body;++ const check = contracts.resources.checkControlRequest(request || {});++ if (!check.valid) return contracts.http.sendProblem(res, 400, 'control.bad_request', { detail: JSON.stringify(check.errors).slice(0, 500) });++ const caller = req.principal.sub;++ const digest = digestOf(request);++ const seen = await db.get('SELECT digest, response FROM media_control_log WHERE caller = ? AND project_id = ? AND idempotency_key = ?',++ [caller, request.project_id, request.idempotency_key]);++ if (seen) {++ if (seen.digest !== digest) return contracts.http.sendProblem(res, 409, 'control.idempotency_conflict', { detail: 'this idempotency_key was used for another request' });++ return res.json(typeof seen.response === 'string' ? JSON.parse(seen.response) : seen.response);++ }++ const answer = await decide(request, caller);++ await db.run(`INSERT INTO media_control_log (caller, project_id, idempotency_key, digest, response, created_at)++ VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING`, [caller, request.project_id, request.idempotency_key, digest, JSON.stringify(answer), now()]);++ return res.json(answer);++ } catch (err) { return next(err); }++ });++ return r;++}++++module.exports = { router, CAPABILITY, digestOf };@@@ -26,12 +26,12 @@ const quiet = { log() {}, warn() {}, error() {} };// Within the window after 0001 the drop waits, and nothing after it runs.const held = await db.migrate({ dir: MIGRATIONS, log: quiet });-- assert.deepStrictEqual(held.held.map((h) => h.id), ['0007'], 'the contract waits for its N-1 window');++ assert.strictEqual(held.held.map((h) => h.id)[0], '0007', 'the contract waits for its N-1 window (and every later migration behind it)');assert.ok(await db.value("SELECT to_regclass('pastes')::text"), 'the tables are still there while it waits');// A week later it runs.const later = await db.migrate({ dir: MIGRATIONS, now: () => Date.now() + 8 * 86400000, log: quiet });-- assert.deepStrictEqual(later.applied.map((a) => a.id), ['0007']);++ assert.strictEqual(later.applied.map((a) => a.id)[0], '0007');for (const t of ['pastes', 'paste_likes', 'paste_comments']) {assert.strictEqual(await db.value(`SELECT to_regclass('${t}')::text`), null, `${t} is dropped`);}nnew file mode 100644@@@ -0,0 +1,122 @@++'use strict';++// Media's control API (ADR-048, plan T13 step 10; capability media.resource.control): OpenVibe.Services deletes a++// person's object, or an object of a project the person administers, with a first-party service token on loopback.++// The delete is the API's soft delete (restorable until the retention period ends); held and legacy objects are refused.++const assert = require('assert');++const crypto = require('crypto');++const http = require('http');++++process.env.OV_NETWORK_URL = 'https://openvibe.network';++process.env.MEDIA_PUBLIC_URL = 'https://media.test';++++(async () => {++ const express = require('express');++ const { ids, serviceAuth, validate } = require('openvibe-contracts');++ const auth = require('../server/auth');++ const db = require('../server/db/database');++ const { guard } = require('../server/service-guard');++ const control = require('../server/registry/resource-control');++++ const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });++ auth._setNetworkPublicKeyForTests(keys.publicKey);++ const now = Math.floor(Date.now() / 1000);++ const tok = (over = {}) => serviceAuth.signServiceToken({++ iss: 'https://openvibe.network', sub: 'svc:services', actor_type: 'service',++ aud: ['openvibe.media'], cap: ['media.resource.control'], iat: now, exp: now + 300, jti: `tok_rc_${crypto.randomBytes(6).toString('hex')}`, ...over,++ }, keys.privateKey);++++ const admins = new Set();++ let networkDown = false;++ const projects = { isAdmin: async (p, s) => { if (networkDown) throw new Error('down'); return admins.has(`${p}|${s}`); } };++ const app = express();++ app.use('/api/v1/resources', control.router({ guard: guard('media.resource.control'), projects, db }));++ const server = http.createServer(app);++++ const prjA = ids.newId('project'); const prjB = ids.newId('project');++ const kim = ids.newId('user'); const lee = ids.newId('user');++ await db.run("INSERT INTO apps (app_id, name, api_key_hash, project_id, env) VALUES (?, 'A', '', ?, 'production'), (?, 'B', '', ?, 'production')", [prjA, prjA, prjB, prjB]);++ const at = '2026-10-01 00:00:00';++ const obj = async (app, owner, { legacy = null, status = 'ready' } = {}) => {++ const id = ids.newId('media');++ await db.run("INSERT INTO media_objects (id, app_id, namespace, kind, owner_subject, visibility, lifecycle_status, legacy_ref, created_at, updated_at) VALUES (?, ?, ?, 'file', ?, 'private', ?, ?, ?, ?)",++ [id, app, app, owner, status, legacy, at, at]);++ return id;++ };++ const statusOf = async (id) => (await db.get('SELECT lifecycle_status FROM media_objects WHERE id = ?', [id])).lifecycle_status;++ const ovrn = (id, prj = prjA) => `ovrn:media:${prj}:object/${id}`;++ const ask = (id, who, extra = {}) => ({ action: 'delete', resource: ovrn(id), project_id: prjA, idempotency_key: `k-${crypto.randomUUID()}`, on_behalf_of: { type: 'user', id: who }, ...extra });++ const valid = (body) => assert.deepStrictEqual(validate('common.resource-control-result@1', body).errors, [], JSON.stringify(body));++++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));++ const base = `http://127.0.0.1:${server.address().port}`;++ const post = async (body, token = tok(), headers = {}) => {++ const r = await fetch(`${base}/api/v1/resources/control`, { method: 'POST', headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}), ...headers }, body: JSON.stringify(body) });++ return { status: r.status, body: await r.json().catch(() => null) };++ };++ let passed = 0;++ const check = async (name, fn) => { await fn(); passed++; };++ try {++ await check('a first-party service token with media.resource.control, on loopback, and nothing else', async () => {++ const id = await obj(prjA, kim);++ const body = ask(id, kim, { dry_run: true });++ assert.strictEqual((await post(body, null)).status, 401);++ assert.strictEqual((await post(body, tok({ cap: ['media.resource.read'] }))).status, 403);++ assert.ok([401, 403].includes((await post(body, tok({ sub: `app:${ids.newId('app')}`, actor_type: 'app' }))).status), 'an app token never controls');++ assert.strictEqual((await post(body, tok(), { 'x-forwarded-for': '203.0.113.9' })).status, 404, 'nothing proxied reaches it');++ assert.strictEqual((await post({ action: 'delete' })).status, 400);++ });++++ await check('the owner deletes their object: a soft delete they can restore; a repeat replays the answer', async () => {++ const id = await obj(prjA, kim);++ const body = ask(id, kim);++ const first = await post(body);++ assert.strictEqual(first.status, 200);++ valid(first.body);++ assert.deepStrictEqual([first.body.state, first.body.result.status, first.body.result.changed], ['done', 'deleted', true]);++ assert.ok(Date.parse(first.body.result.restorable_until) > Date.now(), 'restorable until its retention period ends');++ assert.strictEqual(await statusOf(id), 'deleted');++ assert.deepStrictEqual((await post(body)).body, first.body, 'the same key answers the stored result');++ const conflict = await post({ ...body, dry_run: true });++ assert.deepStrictEqual([conflict.status, conflict.body.code], [409, 'control.idempotency_conflict']);++ const again = await post(ask(id, kim));++ assert.deepStrictEqual([again.body.state, again.body.result], ['done', { status: 'deleted', changed: false }]);++ });++++ await check('an admin of the project deletes what its app stored; anyone else is told it does not exist', async () => {++ const id = await obj(prjA, null);++ const refused = await post(ask(id, lee));++ valid(refused.body);++ assert.deepStrictEqual([refused.body.state, refused.body.problem.code], ['refused', 'resources.unknown_resource']);++ admins.add(`${prjA}|${lee}`);++ const dry = await post(ask(id, lee, { dry_run: true }));++ assert.deepStrictEqual([dry.body.state, dry.body.result.dry_run], ['done', true]);++ assert.strictEqual(await statusOf(id), 'ready', 'a dry run applies nothing');++ networkDown = true;++ const down = await post(ask(id, lee));++ valid(down.body);++ assert.deepStrictEqual([down.body.state, down.body.problem.code], ['failed', 'identity.unavailable']);++ networkDown = false;++ assert.strictEqual((await post(ask(id, lee))).body.result.changed, true);++ admins.clear();++ });++++ await check('held and legacy objects are refused; only delete; only inside the object\'s own project', async () => {++ const held = await obj(prjA, kim);++ await db.run("INSERT INTO media_holds (object_id, kind, reason) VALUES (?, 'moderation', 'report under review')", [held]);++ assert.strictEqual((await post(ask(held, kim))).body.problem.code, 'media.object.held');++ const legacy = await obj(prjA, kim, { legacy: `vod:${crypto.randomInt(1e9)}` });++ assert.strictEqual((await post(ask(legacy, kim))).body.problem.code, 'media.object.legacy_managed');++ const id = await obj(prjA, kim);++ const stop = await post({ ...ask(id, kim), action: 'stop' });++ valid(stop.body);++ assert.strictEqual(stop.body.problem.code, 'control.unsupported_action');++ const elsewhere = await obj(prjB, kim);++ assert.strictEqual((await post({ ...ask(elsewhere, kim), resource: ovrn(elsewhere, prjA) })).body.problem.code, 'resources.unknown_resource');++ for (const x of [held, legacy, id, elsewhere]) assert.strictEqual(await statusOf(x), 'ready');++ });++ } finally {++ server.close();++ }++ console.log(`resource control: ${passed} checks passed`);++ process.exit(0);++})().catch((err) => { console.error(err); process.exit(1); });
@@ -59,6 +59,7 @@ key ([Tenancy & auth](#tenancy--auth)).
@@ -59,6 +59,7 @@ key ([Tenancy & auth](#tenancy--auth)).
| Capability | Audience | Route | Notes |
| Capability | Audience | Route | Notes |
|---|---|---|---|
|---|---|---|---|
| `media.resource.read` | first-party | `GET /api/v1/resources`, `GET /api/v1/resources/:ovrn` | the authority resource index ([Resource index](#resource-index-apiv1resources-first-party)). Active in openvibe-contracts since 0.108.0; the route enforces it (a Network service token holding it, loopback, audience `openvibe.media`) |
| `media.resource.read` | first-party | `GET /api/v1/resources`, `GET /api/v1/resources/:ovrn` | the authority resource index ([Resource index](#resource-index-apiv1resources-first-party)). Active in openvibe-contracts since 0.108.0; the route enforces it (a Network service token holding it, loopback, audience `openvibe.media`) |
+| `media.resource.control` | first-party | `POST /api/v1/resources/control` | `delete` a person's (or their project's) object: the soft delete, restorable until its retention ends ([Resource index](#resource-index-apiv1resources-first-party)). Active in openvibe-contracts since 0.169.0 |
Called elsewhere, as the service principal `media` (the OAuth client `media`):
Called elsewhere, as the service principal `media` (the OAuth client `media`):
@@ -96,6 +97,8 @@ server/
@@ -96,6 +97,8 @@ server/
files/routes.js /api/v1/:app/files
files/routes.js /api/v1/:app/files
admin/routes.js /api/v1/:app/admin/storage (disk, tiers, buckets, bulk ops)
admin/routes.js /api/v1/:app/admin/storage (disk, tiers, buckets, bulk ops)
registry/resource-index.js the authority resource index (/api/v1/resources; common.resource-summary@1, ADR-048)
registry/resource-index.js the authority resource index (/api/v1/resources; common.resource-summary@1, ADR-048)
+ registry/resource-control.js the control API (POST /api/v1/resources/control; delete an object, ADR-048)
+ registry/projects.js whether a person administers a project (Network's internal membership read)
thumbnails/ thumbnail service + /api/v1/:app/thumbnails
thumbnails/ thumbnail service + /api/v1/:app/thumbnails
public/routes.js public /v /c /p /t /f
public/routes.js public /v /c /p /t /f
me/ the object explorer: /me and /api/v2/me (a signed-in person's own objects, uploads,
me/ the object explorer: /me and /api/v2/me (a signed-in person's own objects, uploads,
@@ -123,7 +126,7 @@ server/
@@ -123,7 +126,7 @@ server/
storage.orphans.scan (monthly storage orphan report, service-wide, report only),
storage.orphans.scan (monthly storage orphan report, service-wide, report only),
storage.move.cleanup (the alert for placement moves whose delete keeps failing, service-wide)
storage.move.cleanup (the alert for placement moves whose delete keeps failing, service-wide)
client-ip.js trust proxy = loopback; req.ip is the only client address
client-ip.js trust proxy = loopback; req.ip is the only client address
-(openvibe-shared v4.1.3, openvibe-contracts v0.143.0, openvibe-sdk v0.44.0: pinned release tarballs, installed by npm)
+(openvibe-shared v4.1.3, openvibe-contracts v0.169.0, openvibe-sdk v0.44.0: pinned release tarballs, installed by npm)
scripts/smoke-test.sh end-to-end smoke test (boots a temp instance)
scripts/smoke-test.sh end-to-end smoke test (boots a temp instance)
scripts/reconcile-objects.js / object-invariant.js / no-good-copy-report.js object-model operator tools
scripts/reconcile-objects.js / object-invariant.js / no-good-copy-report.js object-model operator tools
scripts/media-jobs.js list jobs, run the size-invariant scan (dry run by default), approve/cancel proposals
scripts/media-jobs.js list jobs, run the size-invariant scan (dry run by default), approve/cancel proposals
@@ -404,6 +407,15 @@ its objects carry no `project_id` and no `ovrn`. The summary's `ovrn` is compose
@@ -404,6 +407,15 @@ its objects carry no `project_id` and no `ovrn`. The summary's `ovrn` is compose
resource whose computed name equals the one asked for answers. `owner` is a `subject-ref` when the object
resource whose computed name equals the one asked for answers. `owner` is a `subject-ref` when the object
stores a `usr_` owner. `test/resource-index.test.js`.
stores a `usr_` owner. `test/resource-index.test.js`.
+**Control** (`media.resource.control`, plan T13 step 10): `POST /api/v1/resources/control` takes a
+`common.resource-control-request@1` from OpenVibe.Services (same loopback service-token check, and the caller must
+be a `svc:` service) and answers a `common.resource-control-result@1`. The one action is `delete`: the API's soft
+delete (`restorable_until` in the result; restore through `POST /api/v1/objects/:id/restore`). A held object and a
+legacy-managed one (a VOD, clip or file) are refused. The object's owner may act, and so may an admin or owner of its
+project (asked of Network with `network.project.member.read`, `server/registry/projects.js`); anyone else gets
+`resources.unknown_resource`. Dry runs; the first answer per (caller, project, key) is kept in `media_control_log` and
+returned for a repeat. `test/resource-control.test.js`.
+
## Restore drills (`MEDIA_DRILL=1`)
## Restore drills (`MEDIA_DRILL=1`)
A restore drill starts a second Media from this checkout on 127.0.0.1:14100 with `MEDIA_DRILL=1` and
A restore drill starts a second Media from this checkout on 127.0.0.1:14100 with `MEDIA_DRILL=1` and
new file mode 100644
new file mode 100644
@@ -0,0 +1,12 @@
@@ -0,0 +1,12 @@
+-- phase: expand
+-- ADR-048 control operations (plan T13 step 10, server/registry/resource-control.js): the first answer to each
+-- (caller, project, idempotency_key), returned again for a repeat so a retried control request never applies twice.
+CREATE TABLE media_control_log (
+ caller text COLLATE "C" NOT NULL, -- svc:… that sent it
+ project_id text COLLATE "C" NOT NULL,
+ idempotency_key text COLLATE "C" NOT NULL,
+ digest text COLLATE "C" NOT NULL, -- sha256 of the request without its delivery fields
+ response jsonb NOT NULL, -- the common.resource-control-result@1 answered
+ created_at bigint NOT NULL,
+ PRIMARY KEY (caller, project_id, idempotency_key)
+);
@@ -16,7 +16,7 @@
@@ -16,7 +16,7 @@
"express": "^4.19.2",
"express": "^4.19.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.143.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.169.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.3",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.3",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -1847,9 +1847,9 @@
@@ -1847,9 +1847,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.143.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.143.0",
- "integrity": "sha512-Cdbf+9RoPAUBibpM2zXQvtsiKMugb55dKubTt8pFZvAmxsLtGvX+zaC++yL1vOSub8FX3+GgqcGtoJS6tklrUg==",
+ "version": "0.169.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.169.0",
+ "integrity": "sha512-Py2z8UaKGvLzG3atuIAnJKnRe74Te9cJRTaxqFq6m4fov6gaMXeS6rn7lH0tSIo0qBGD5trrT618ARXnklkp+A==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -20,7 +20,7 @@
@@ -20,7 +20,7 @@
"express": "^4.19.2",
"express": "^4.19.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.143.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.169.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.3",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.3",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -220,6 +220,7 @@ const ready = (async () => {
@@ -220,6 +220,7 @@ const ready = (async () => {
// Media owns — its media objects — as common.resource-summary@1, for OpenVibe.Services to fan out and merge.
// Media owns — its media objects — as common.resource-summary@1, for OpenVibe.Services to fan out and merge.
// Loopback and a Network service token, like the other first-party routes (server/service-guard.js); mounted
// Loopback and a Network service token, like the other first-party routes (server/service-guard.js); mounted
// ahead of /api/v1/:app/… so /api/v1/resources is never read as a tenant named "resources".
// ahead of /api/v1/:app/… so /api/v1/resources is never read as a tenant named "resources".
+ app.use('/api/v1/resources', require('./registry/resource-control').router({ guard: require('./service-guard').guard('media.resource.control') }));
app.use('/api/v1/resources', require('./registry/resource-index').router({ guard: require('./service-guard').guard('media.resource.read') }));
app.use('/api/v1/resources', require('./registry/resource-index').router({ guard: require('./service-guard').guard('media.resource.read') }));
app.use('/api/v1/:app/views', require('./views/routes'));
app.use('/api/v1/:app/views', require('./views/routes'));
app.use('/api/v1/:app/vods', require('./vod/routes'));
app.use('/api/v1/:app/vods', require('./vod/routes'));
new file mode 100644
new file mode 100644
@@ -0,0 +1,40 @@
@@ -0,0 +1,40 @@
+'use strict';
+/**
+ * Project delegation (ADR-003 amendment 2026-10-12): whether a person is an admin or owner of an unarchived project,
+ * asked of Network (GET /internal/projects/:project/members/:subject, capability network.project.member.read) with
+ * Media's own client-credentials token. The control API (server/registry/resource-control.js) uses it so a project's
+ * admins can delete the objects its apps stored.
+ *
+ * await createProjects().isAdmin(projectId, subjectId) → true | false; throws when Network cannot answer
+ */
+const { serviceAuth } = require('openvibe-contracts');
+const config = require('../config');
+
+const ADMIN_ROLES = new Set(['admin', 'owner']);
+
+function createProjects({
+ networkUrl = config.network.internalUrl,
+ clientId = process.env.OV_OAUTH_CLIENT_ID || 'media',
+ clientSecret = process.env.OV_OAUTH_CLIENT_SECRET || '',
+ fetchImpl = globalThis.fetch,
+} = {}) {
+ const base = String(networkUrl || '').replace(/\/+$/, '');
+ const tokens = clientSecret ? serviceAuth.createTokenClient({
+ tokenUrl: `${base}/oauth/token`, clientId, clientSecret, audience: 'openvibe.network', scope: 'network.project.member.read', fetchImpl,
+ }) : null;
+
+ async function isAdmin(projectId, subjectId) {
+ if (!tokens) throw new Error('set OV_OAUTH_CLIENT_SECRET so Media can read project roles from Network');
+ const res = await fetchImpl(`${base}/internal/projects/${encodeURIComponent(projectId)}/members/${encodeURIComponent(subjectId)}`, {
+ headers: { Accept: 'application/json', ...(await tokens.authHeaders()) }, signal: AbortSignal.timeout(10_000),
+ });
+ if (res.status === 404) return false;
+ if (!res.ok) throw new Error(`Network answered ${res.status} for a project membership`);
+ const out = await res.json().catch(() => null);
+ return !!out && out.project_id === projectId && out.subject === subjectId && ADMIN_ROLES.has(out.role) && out.archived === false;
+ }
+
+ return { isAdmin };
+}
+
+module.exports = { createProjects, ADMIN_ROLES };
new file mode 100644
new file mode 100644
@@ -0,0 +1,108 @@
@@ -0,0 +1,108 @@
+'use strict';
+/**
+ * Media's control API (ADR-048, plan T13 step 10; capability media.resource.control):
+ *
+ * POST /api/v1/resources/control common.resource-control-request@1 → common.resource-control-result@1
+ *
+ * From a first-party service (OpenVibe.Services) on loopback, acting for the person in on_behalf_of, on a media.object
+ * named by its OVRN. 200 for every outcome; a malformed request is a 400 problem.
+ *
+ * delete a soft delete, exactly as DELETE /api/v1/objects/:id: the object stops being served at once and can be
+ * restored through the API until its retention period ends. An object under a retention hold, and a legacy
+ * VOD, clip or file (managed through its own routes), is refused.
+ *
+ * Any other action is refused `control.unsupported_action`. The object's owner may act on it, and so may an admin or
+ * owner of its project (asked of Network: server/registry/projects.js), which is how a project's admins delete what
+ * its apps stored. Anyone else is refused as not found, never confirmed to exist; and only inside the project the OVRN
+ * names. `dry_run` decides and explains without applying. The first answer per (caller, project, idempotency_key) is
+ * kept in media_control_log and returned for a repeat; the same key with another request is refused
+ * `control.idempotency_conflict`.
+ */
+const crypto = require('crypto');
+const express = require('express');
+const contracts = require('openvibe-contracts');
+
+const SERVICE = 'media';
+const CAPABILITY = 'media.resource.control';
+const USER_SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+const OBJECT_ID_RE = /^med_[0-9A-HJKMNP-TV-Z]{26}$/;
+
+function canonical(v) {
+ if (Array.isArray(v)) return `[${v.map(canonical).join(',')}]`;
+ if (v && typeof v === 'object') return `{${Object.keys(v).sort().filter((k) => v[k] !== undefined).map((k) => `${JSON.stringify(k)}:${canonical(v[k])}`).join(',')}}`;
+ return JSON.stringify(v);
+}
+const digestOf = (request) => crypto.createHash('sha256')
+ .update(canonical({ ...request, idempotency_key: undefined, confirmation_id: undefined, trace_id: undefined })).digest('hex');
+
+function router({ guard, projects = require('./projects').createProjects(), db = require('../db/database'), now = () => Date.now() }) {
+ const r = express.Router();
+ const json = express.json({ limit: '16kb' });
+ const iso = () => new Date(now()).toISOString();
+ const result = (request, state, extra = {}) => ({ action: request.action, ...(request.resource ? { resource: request.resource } : {}), state, at: iso(), ...extra });
+ const problem = (status, code, detail) => ({ type: `https://openvibe.network/problems/${code}`, title: code, status, code, detail });
+
+ async function decide(request, caller) {
+ const parsed = contracts.resources.parse(request.resource || '');
+ if (!parsed || parsed.service !== SERVICE || parsed.type !== 'object' || !OBJECT_ID_RE.test(parsed.id)) {
+ return result(request, 'refused', { problem: problem(404, 'resources.unknown_resource', `no object named ${request.resource || request.resource_kind}`) });
+ }
+ if (request.action !== 'delete') {
+ return result(request, 'refused', { problem: problem(422, 'control.unsupported_action', `an object takes delete, not ${request.action}`) });
+ }
+ const person = request.on_behalf_of;
+ if (!person || person.type !== 'user' || !USER_SUBJECT_RE.test(String(person.id || ''))) {
+ return result(request, 'refused', { problem: problem(403, 'control.no_person', 'on_behalf_of must name the person acting') });
+ }
+ const obj = await db.get('SELECT o.*, a.project_id AS project FROM media_objects o LEFT JOIN apps a ON a.app_id = o.app_id WHERE o.id = ?', [parsed.id]);
+ const inProject = !!obj && obj.project === parsed.project_id && parsed.project_id === request.project_id;
+ let allowed = inProject && obj.owner_subject === person.id;
+ if (inProject && !allowed) {
+ try { allowed = await projects.isAdmin(obj.project, person.id); } catch {
+ return result(request, 'failed', { problem: problem(503, 'identity.unavailable', 'Network could not say whether this person administers the project; nothing was changed') });
+ }
+ }
+ if (!allowed) return result(request, 'refused', { problem: problem(404, 'resources.unknown_resource', `no object named ${request.resource} that this person may delete`) });
+
+ const model = require('../objects/model');
+ if (obj.legacy_ref) return result(request, 'refused', { problem: problem(409, 'media.object.legacy_managed', 'a VOD, clip or file is deleted through its own routes') });
+ if (obj.lifecycle_status === 'deleted') return result(request, 'done', { result: { status: 'deleted', changed: false } });
+ if (await model.isHeld(obj.id)) return result(request, 'refused', { problem: problem(409, 'media.object.held', 'the object is under a retention hold') });
+ if (request.dry_run) return result(request, 'done', { result: { dry_run: true, would: 'delete the object (restorable until its retention period ends)' } });
+ let gone;
+ try { gone = await model.softDelete(obj, { by: `user:${person.id} via ${caller}` }); } catch (err) {
+ if (err && (err.code === 'media.object.held' || /retention hold/.test(err.message))) return result(request, 'refused', { problem: problem(409, 'media.object.held', 'the object is under a retention hold') });
+ throw err;
+ }
+ const app = await db.getApp(obj.app_id);
+ if (app) await require('../objects/namespaces').reconcileChain(app, obj.namespace);
+ const md = model.parseJson(gone && gone.metadata, {});
+ return result(request, 'done', { result: { status: 'deleted', changed: true, ...(md.retention_until ? { restorable_until: md.retention_until } : {}) } });
+ }
+
+ r.post('/control', guard, json, async (req, res, next) => {
+ try {
+ res.set('Cache-Control', 'no-store');
+ // First-party: the capability is for OpenVibe's own services, never an app's or agent's token.
+ if (!String(req.principal.sub || '').startsWith('svc:')) return contracts.http.sendProblem(res, 403, 'capability.denied', { detail: `${CAPABILITY} requires a service token` });
+ const request = req.body;
+ const check = contracts.resources.checkControlRequest(request || {});
+ if (!check.valid) return contracts.http.sendProblem(res, 400, 'control.bad_request', { detail: JSON.stringify(check.errors).slice(0, 500) });
+ const caller = req.principal.sub;
+ const digest = digestOf(request);
+ const seen = await db.get('SELECT digest, response FROM media_control_log WHERE caller = ? AND project_id = ? AND idempotency_key = ?',
+ [caller, request.project_id, request.idempotency_key]);
+ if (seen) {
+ if (seen.digest !== digest) return contracts.http.sendProblem(res, 409, 'control.idempotency_conflict', { detail: 'this idempotency_key was used for another request' });
+ return res.json(typeof seen.response === 'string' ? JSON.parse(seen.response) : seen.response);
+ }
+ const answer = await decide(request, caller);
+ await db.run(`INSERT INTO media_control_log (caller, project_id, idempotency_key, digest, response, created_at)
+ VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING`, [caller, request.project_id, request.idempotency_key, digest, JSON.stringify(answer), now()]);
+ return res.json(answer);
+ } catch (err) { return next(err); }
+ });
+ return r;
+}
+
+module.exports = { router, CAPABILITY, digestOf };
@@ -26,12 +26,12 @@ const quiet = { log() {}, warn() {}, error() {} };
@@ -26,12 +26,12 @@ const quiet = { log() {}, warn() {}, error() {} };
// Within the window after 0001 the drop waits, and nothing after it runs.
// Within the window after 0001 the drop waits, and nothing after it runs.
const held = await db.migrate({ dir: MIGRATIONS, log: quiet });
const held = await db.migrate({ dir: MIGRATIONS, log: quiet });
- assert.deepStrictEqual(held.held.map((h) => h.id), ['0007'], 'the contract waits for its N-1 window');
+ assert.strictEqual(held.held.map((h) => h.id)[0], '0007', 'the contract waits for its N-1 window (and every later migration behind it)');
assert.ok(await db.value("SELECT to_regclass('pastes')::text"), 'the tables are still there while it waits');
assert.ok(await db.value("SELECT to_regclass('pastes')::text"), 'the tables are still there while it waits');
// A week later it runs.
// A week later it runs.
const later = await db.migrate({ dir: MIGRATIONS, now: () => Date.now() + 8 * 86400000, log: quiet });
const later = await db.migrate({ dir: MIGRATIONS, now: () => Date.now() + 8 * 86400000, log: quiet });
- assert.deepStrictEqual(later.applied.map((a) => a.id), ['0007']);
+ assert.strictEqual(later.applied.map((a) => a.id)[0], '0007');
for (const t of ['pastes', 'paste_likes', 'paste_comments']) {
for (const t of ['pastes', 'paste_likes', 'paste_comments']) {
assert.strictEqual(await db.value(`SELECT to_regclass('${t}')::text`), null, `${t} is dropped`);
assert.strictEqual(await db.value(`SELECT to_regclass('${t}')::text`), null, `${t} is dropped`);
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,122 @@
@@ -0,0 +1,122 @@
+'use strict';
+// Media's control API (ADR-048, plan T13 step 10; capability media.resource.control): OpenVibe.Services deletes a
+// person's object, or an object of a project the person administers, with a first-party service token on loopback.
+// The delete is the API's soft delete (restorable until the retention period ends); held and legacy objects are refused.
+const assert = require('assert');
+const crypto = require('crypto');
+const http = require('http');
+
+process.env.OV_NETWORK_URL = 'https://openvibe.network';
+process.env.MEDIA_PUBLIC_URL = 'https://media.test';
+
+(async () => {
+ const express = require('express');
+ const { ids, serviceAuth, validate } = require('openvibe-contracts');
+ const auth = require('../server/auth');
+ const db = require('../server/db/database');
+ const { guard } = require('../server/service-guard');
+ const control = require('../server/registry/resource-control');
+
+ const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
+ auth._setNetworkPublicKeyForTests(keys.publicKey);
+ const now = Math.floor(Date.now() / 1000);
+ const tok = (over = {}) => serviceAuth.signServiceToken({
+ iss: 'https://openvibe.network', sub: 'svc:services', actor_type: 'service',
+ aud: ['openvibe.media'], cap: ['media.resource.control'], iat: now, exp: now + 300, jti: `tok_rc_${crypto.randomBytes(6).toString('hex')}`, ...over,
+ }, keys.privateKey);
+
+ const admins = new Set();
+ let networkDown = false;
+ const projects = { isAdmin: async (p, s) => { if (networkDown) throw new Error('down'); return admins.has(`${p}|${s}`); } };
+ const app = express();
+ app.use('/api/v1/resources', control.router({ guard: guard('media.resource.control'), projects, db }));
+ const server = http.createServer(app);
+
+ const prjA = ids.newId('project'); const prjB = ids.newId('project');
+ const kim = ids.newId('user'); const lee = ids.newId('user');
+ await db.run("INSERT INTO apps (app_id, name, api_key_hash, project_id, env) VALUES (?, 'A', '', ?, 'production'), (?, 'B', '', ?, 'production')", [prjA, prjA, prjB, prjB]);
+ const at = '2026-10-01 00:00:00';
+ const obj = async (app, owner, { legacy = null, status = 'ready' } = {}) => {
+ const id = ids.newId('media');
+ await db.run("INSERT INTO media_objects (id, app_id, namespace, kind, owner_subject, visibility, lifecycle_status, legacy_ref, created_at, updated_at) VALUES (?, ?, ?, 'file', ?, 'private', ?, ?, ?, ?)",
+ [id, app, app, owner, status, legacy, at, at]);
+ return id;
+ };
+ const statusOf = async (id) => (await db.get('SELECT lifecycle_status FROM media_objects WHERE id = ?', [id])).lifecycle_status;
+ const ovrn = (id, prj = prjA) => `ovrn:media:${prj}:object/${id}`;
+ const ask = (id, who, extra = {}) => ({ action: 'delete', resource: ovrn(id), project_id: prjA, idempotency_key: `k-${crypto.randomUUID()}`, on_behalf_of: { type: 'user', id: who }, ...extra });
+ const valid = (body) => assert.deepStrictEqual(validate('common.resource-control-result@1', body).errors, [], JSON.stringify(body));
+
+ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const post = async (body, token = tok(), headers = {}) => {
+ const r = await fetch(`${base}/api/v1/resources/control`, { method: 'POST', headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}), ...headers }, body: JSON.stringify(body) });
+ return { status: r.status, body: await r.json().catch(() => null) };
+ };
+ let passed = 0;
+ const check = async (name, fn) => { await fn(); passed++; };
+ try {
+ await check('a first-party service token with media.resource.control, on loopback, and nothing else', async () => {
+ const id = await obj(prjA, kim);
+ const body = ask(id, kim, { dry_run: true });
+ assert.strictEqual((await post(body, null)).status, 401);
+ assert.strictEqual((await post(body, tok({ cap: ['media.resource.read'] }))).status, 403);
+ assert.ok([401, 403].includes((await post(body, tok({ sub: `app:${ids.newId('app')}`, actor_type: 'app' }))).status), 'an app token never controls');
+ assert.strictEqual((await post(body, tok(), { 'x-forwarded-for': '203.0.113.9' })).status, 404, 'nothing proxied reaches it');
+ assert.strictEqual((await post({ action: 'delete' })).status, 400);
+ });
+
+ await check('the owner deletes their object: a soft delete they can restore; a repeat replays the answer', async () => {
+ const id = await obj(prjA, kim);
+ const body = ask(id, kim);
+ const first = await post(body);
+ assert.strictEqual(first.status, 200);
+ valid(first.body);
+ assert.deepStrictEqual([first.body.state, first.body.result.status, first.body.result.changed], ['done', 'deleted', true]);
+ assert.ok(Date.parse(first.body.result.restorable_until) > Date.now(), 'restorable until its retention period ends');
+ assert.strictEqual(await statusOf(id), 'deleted');
+ assert.deepStrictEqual((await post(body)).body, first.body, 'the same key answers the stored result');
+ const conflict = await post({ ...body, dry_run: true });
+ assert.deepStrictEqual([conflict.status, conflict.body.code], [409, 'control.idempotency_conflict']);
+ const again = await post(ask(id, kim));
+ assert.deepStrictEqual([again.body.state, again.body.result], ['done', { status: 'deleted', changed: false }]);
+ });
+
+ await check('an admin of the project deletes what its app stored; anyone else is told it does not exist', async () => {
+ const id = await obj(prjA, null);
+ const refused = await post(ask(id, lee));
+ valid(refused.body);
+ assert.deepStrictEqual([refused.body.state, refused.body.problem.code], ['refused', 'resources.unknown_resource']);
+ admins.add(`${prjA}|${lee}`);
+ const dry = await post(ask(id, lee, { dry_run: true }));
+ assert.deepStrictEqual([dry.body.state, dry.body.result.dry_run], ['done', true]);
+ assert.strictEqual(await statusOf(id), 'ready', 'a dry run applies nothing');
+ networkDown = true;
+ const down = await post(ask(id, lee));
+ valid(down.body);
+ assert.deepStrictEqual([down.body.state, down.body.problem.code], ['failed', 'identity.unavailable']);
+ networkDown = false;
+ assert.strictEqual((await post(ask(id, lee))).body.result.changed, true);
+ admins.clear();
+ });
+
+ await check('held and legacy objects are refused; only delete; only inside the object\'s own project', async () => {
+ const held = await obj(prjA, kim);
+ await db.run("INSERT INTO media_holds (object_id, kind, reason) VALUES (?, 'moderation', 'report under review')", [held]);
+ assert.strictEqual((await post(ask(held, kim))).body.problem.code, 'media.object.held');
+ const legacy = await obj(prjA, kim, { legacy: `vod:${crypto.randomInt(1e9)}` });
+ assert.strictEqual((await post(ask(legacy, kim))).body.problem.code, 'media.object.legacy_managed');
+ const id = await obj(prjA, kim);
+ const stop = await post({ ...ask(id, kim), action: 'stop' });
+ valid(stop.body);
+ assert.strictEqual(stop.body.problem.code, 'control.unsupported_action');
+ const elsewhere = await obj(prjB, kim);
+ assert.strictEqual((await post({ ...ask(elsewhere, kim), resource: ovrn(elsewhere, prjA) })).body.problem.code, 'resources.unknown_resource');
+ for (const x of [held, legacy, id, elsewhere]) assert.strictEqual(await statusOf(x), 'ready');
+ });
+ } finally {
+ server.close();
+ }
+ console.log(`resource control: ${passed} checks passed`);
+ process.exit(0);
+})().catch((err) => { console.error(err); process.exit(1); });