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)

OP OpenVibers committed 30 days ago · f8dfe17a9a · 9 files, +303 −8

Plan T13 step 10 (contracts 0.169.0): Media is an authority behind the Services control route.

**`POST /api/v1/resources/control`** (`media.resource.control`; the same loopback service-token guard as the resource index, and the caller must be a `svc:` service):
- `delete` is the API's soft delete (`DELETE /api/v1/objects/:id`). The result carries `restorable_until`, and `POST /api/v1/objects/:id/restore` brings the object back until then. The namespace usage chain is reconciled.
- A held object is refused `media.object.held`. A legacy-managed one (a VOD, clip or file) is refused `media.object.legacy_managed`. An object already deleted answers `done` with `changed: false`.
- Who may act: the object's owner, or an admin or owner of its project. The project role is read from Network (`server/registry/projects.js`, `network.project.member.read`), so a project's admins can delete what its apps stored. Anyone else gets `resources.unknown_resource`. If Network cannot answer, the result is `failed` with `identity.unavailable` and nothing changes.
- Every other action is `control.unsupported_action`.
- Dry runs are supported. The first answer per (caller, project, key) is kept in `media_control_log` (migration 0008, expand; production has 0007 applied, so it runs at once) and returned for a repeat; another request under the same key is a 409.
- Contracts are pinned to v0.169.0. The README documents the route, the capability row and the file map.

**Tests**
- `test/resource-control.test.js` (4).
- `paste-tables-dropped` no longer assumes 0007 is the last migration.
- `pins` follows the README.

Changes

ddiff --git a/README.md b/README.md
iindex 0339313..79be86e 100644
---- a/README.md
++++ b/README.md
@@@ -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/files
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-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
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,
@@@ -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 tools
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
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`.
++**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` and
ddiff --git a/migrations/0008_control_log.sql b/migrations/0008_control_log.sql
nnew file mode 100644
iindex 0000000..350fad7
---- /dev/null
++++ b/migrations/0008_control_log.sql
@@@ -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)
++);
ddiff --git a/package-lock.json b/package-lock.json
iindex cc7d156..167ec11 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex 7d49a8e..148a9a7 100644
---- a/package.json
++++ b/package.json
@@@ -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",
ddiff --git a/server/index.js b/server/index.js
iindex 674bfd1..690ba7a 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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'));
ddiff --git a/server/registry/projects.js b/server/registry/projects.js
nnew file mode 100644
iindex 0000000..28f557d
---- /dev/null
++++ b/server/registry/projects.js
@@@ -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 };
ddiff --git a/server/registry/resource-control.js b/server/registry/resource-control.js
nnew file mode 100644
iindex 0000000..07c55aa
---- /dev/null
++++ b/server/registry/resource-control.js
@@@ -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 };
ddiff --git a/test/paste-tables-dropped.test.js b/test/paste-tables-dropped.test.js
iindex 2c671b9..dd979ca 100644
---- a/test/paste-tables-dropped.test.js
++++ b/test/paste-tables-dropped.test.js
@@@ -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`);
}
ddiff --git a/test/resource-control.test.js b/test/resource-control.test.js
nnew file mode 100644
iindex 0000000..13ae348
---- /dev/null
++++ b/test/resource-control.test.js
@@@ -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); });