openvibers/openvibe.space Commits

Space: the control API — Services can delete a site, deploy or domain for a person, by Space's own roles (contracts 0.170) (#45)

OP OpenVibers committed 30 days ago · cf1a2abdf6 · 8 files, +232 −7

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

**`POST /api/v1/resources/control`** (`space.resource.control`; first-party `svc:` tokens only, mounted in the resource index chain so the rate limit and viewer run once). It takes `delete` for a `space.site`, `space.deploy` or `space.domain`.
- It acts as the person in `on_behalf_of` through the same domain code as the API's DELETE routes, so Space's own roles decide:
  - deleting needs maintainer or owner;
  - a lesser member is refused with Space's reason;
  - a non-member gets `resources.unknown_resource`.
- Space's rules refuse the deploy a site is serving (`deploy.active`), a default domain (`domain.default`) and anything taken down. A dry run applies the same rules.
- The first answer per (caller, project, key) is kept in `space_control_log` (migration 0007, expand) and returned for a repeat; another request under the same key is a 409.
- Contracts are pinned to v0.170.0 (was 0.149.0). The README documents the route and the capability.

**Tests**
- `test/resource-control.test.js` (4): guards, a site deleted through it, the role rules, the served deploy, the default domain, dry runs, kinds and projects.
- The full suite passes (25/25), and so does `openvibe-contracts-check --service space` (6 capabilities enforced).

Changes

ddiff --git a/README.md b/README.md
iindex de80750..f0d2502 100644
---- a/README.md
++++ b/README.md
@@@ -37,6 +37,7 @@ Nothing that is not a forum path redirects.
| `/preview/:deployId/*` | Private, sandboxed deploy preview |
| `/api/v1/projects`, `/api/v1/sites`, `/api/v1/deploys`, `/api/v1/domains` | Hosting API (Bearer tokens, no cookie auth) |
| `/api/v1/resources` | First-party resource index |
++| `POST /api/v1/resources/control` | First-party control: `delete` a site, deploy or domain for a person (Space's own roles decide) |
| `/limits.json` | Enforced hosting limits |
| `/auth/login|callback|fedcm|logout|me|refresh` | Space's Network OAuth client |
| `/robots.txt`, `/llms.txt`, `/llms-full.txt`, `/sitemap.xml` | Discovery for Space's public page |
@@@ -48,7 +49,7 @@ Tenant requests are dispatched by Host header before dashboard routes. `deploy/n
## Capabilities and events
--The hosting API guards `space.site.manage`, `space.site.config`, `space.deploy.create`, `space.domain.manage` and `space.resource.read` (openvibe-contracts 0.149.0, owner `space`); every route names exactly one, and `npx openvibe-contracts-check --service space` checks the code against the release. Service tokens need audience `openvibe.space`, and project membership still applies. The per-actor limits (`server/http/actor-limits.js`) count uploads, domain checks, project creation and writes per person or app; those are limit buckets, not capabilities. The retired `space.forum.*` contracts are unrelated to hosting.
++The hosting API guards `space.site.manage`, `space.site.config`, `space.deploy.create`, `space.domain.manage`, `space.resource.read` and `space.resource.control` (openvibe-contracts 0.170.0, owner `space`); every route names exactly one, and `npx openvibe-contracts-check --service space` checks the code against the release. Service tokens need audience `openvibe.space`, and project membership still applies. The per-actor limits (`server/http/actor-limits.js`) count uploads, domain checks, project creation and writes per person or app; those are limit buckets, not capabilities. The retired `space.forum.*` contracts are unrelated to hosting. The control route (`server/registry/resource-control.js`, plan T13 step 10) takes a `common.resource-control-request@1` from OpenVibe.Services and acts as the person in `on_behalf_of` through the same domain code as the DELETE routes: deleting needs maintainer or owner, a non-member gets `resources.unknown_resource`, and the served deploy (`deploy.active`), a default domain (`domain.default`) and anything taken down are refused with Space's own reason. Dry runs; repeats with the same key return the first answer (`space_control_log`).
The transactional outbox emits `space.deploy.created`, `space.deploy.activated`, `space.deploy.failed` and `space.domain.verified`; each has a payload schema in openvibe-contracts, and the SDK outbox refuses any other type.
ddiff --git a/migrations/0007_control_log.sql b/migrations/0007_control_log.sql
nnew file mode 100644
iindex 0000000..f2f9778
---- /dev/null
++++ b/migrations/0007_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 space_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 a5d35b4..be9add2 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -15,7 +15,7 @@
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.149.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.170.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"
@@@ -804,9 +804,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.149.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.149.0",
-- "integrity": "sha512-7TGh8i3dPHHxQsAblWMSPvkigY//mqaUtm1u7T3QtIiBLlGKRlEwCgc3nTd7agd5f7N8aDxfw6z8Ls8bAg/smA==",
++ "version": "0.170.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.170.0",
++ "integrity": "sha512-EpOUiqFSYBZ1p7aQTek1IYyHcbD9QT0pdr492+teWvZf425xXSMu7+DQsnzgMP8JdTmdS2VMxoirrBwMUGxjrw==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex 06dddfd..7d8beb0 100644
---- a/package.json
++++ b/package.json
@@@ -27,7 +27,7 @@
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.149.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.170.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/auth/capabilities.js b/server/auth/capabilities.js
iindex a94e0f5..949318a 100644
---- a/server/auth/capabilities.js
++++ b/server/auth/capabilities.js
@@@ -8,6 +8,7 @@
* space.domain.manage add, verify and remove custom domains
* space.site.config read, set and reset a site's response headers, redirects and SPA fallback
* space.resource.read read the resource index (GET /api/v1/resources; ADR-048 section 3, plan T13 step 8)
++ * space.resource.control delete a site, deploy or domain for a person (POST /api/v1/resources/control; plan T13 step 10)
*
* A service token is judged by its capability AND by the project role of the principal it acts
* as (domain/access.js). Browsers are judged by their role alone.
@@@ -22,6 +23,7 @@ const CAPABILITIES = Object.freeze({
DOMAIN_MANAGE: 'space.domain.manage',
SITE_CONFIG: 'space.site.config',
RESOURCE_READ: 'space.resource.read',
++ RESOURCE_CONTROL: 'space.resource.control',
});
/** → { allowed, code, reason } (capabilities.check(); space.* since openvibe-contracts 0.149.0). */
ddiff --git a/server/hosting.js b/server/hosting.js
iindex ce8774d..8ba0ee9 100644
---- a/server/hosting.js
++++ b/server/hosting.js
@@@ -93,7 +93,7 @@ function createHosting(app, { db, auth, config: suppliedConfig, now, resolver, f
ctx.mountRoutes = (app) => {
if (forumRedirect) app.use(forumRedirect);
app.use(contracts.http.middleware());
-- app.use('/api/v1/resources', rateLimit({ windowMs: 60_000, max: 240, standardHeaders: true, legacyHeaders: false }), viewers.middleware('api'), resourceIndex.router({ guard: guard('space.resource.read') }));
++ app.use('/api/v1/resources', rateLimit({ windowMs: 60_000, max: 240, standardHeaders: true, legacyHeaders: false }), viewers.middleware('api'), require('./registry/resource-control').router({ guard: guard('space.resource.control') }), resourceIndex.router({ guard: guard('space.resource.read') }));
app.use('/api/v1', rateLimit({ windowMs: 60_000, max: 240, standardHeaders: true, legacyHeaders: false }), createApi(ctx));
app.use('/preview', rateLimit({ windowMs: 60_000, max: 300, standardHeaders: true, legacyHeaders: false }), viewers.middleware('dashboard'), (req, res, next) => Promise.resolve(tenant.preview(req, res)).catch(next));
app.use(rateLimit({ windowMs: 60_000, max: 300, standardHeaders: true, legacyHeaders: false }), createDashboard(ctx));
ddiff --git a/server/registry/resource-control.js b/server/registry/resource-control.js
nnew file mode 100644
iindex 0000000..1d0bf15
---- /dev/null
++++ b/server/registry/resource-control.js
@@@ -0,0 +1,116 @@
++'use strict';
++/**
++ * Space's control API (ADR-048, plan T13 step 10; capability space.resource.control):
++ *
++ * POST /api/v1/resources/control common.resource-control-request@1 → common.resource-control-result@1
++ *
++ * From a first-party service (OpenVibe.Services) acting for the person in on_behalf_of, on a space.site, space.deploy or
++ * space.domain named by its OVRN. 200 for every outcome; a malformed request is a 400 problem.
++ *
++ * delete exactly as the API's DELETE /api/v1/sites/:id, /deploys/:id and /domains/:id, acting as that person: a
++ * site stops serving at once and its deploys and domains go with it. Space's own rules refuse the deploy a
++ * site is serving (deploy.active), a site's default domain (domain.default) and anything taken down.
++ *
++ * Any other action is refused `control.unsupported_action`. Space decides by its own project roles (deleting needs
++ * maintainer or owner; domain/access.js), so a person who is not a member is refused as not found, never confirmed to
++ * exist; and only inside the project the OVRN names. `dry_run` checks the role and those rules without applying. The
++ * first answer per (caller, project, idempotency_key) is kept in space_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 { ApiError } = require('../http/errors');
++
++const SERVICE = 'space';
++const CAPABILITY = 'space.resource.control';
++const USER_SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
++const TYPES = { site: /^sit_[0-9A-HJKMNP-TV-Z]{26}$/, deploy: /^dpl_[0-9A-HJKMNP-TV-Z]{26}$/, domain: /^dom_[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, 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 });
++ const unknown = (request) => result(request, 'refused', { problem: problem(404, 'resources.unknown_resource', `no ${request.resource || request.resource_kind} that this person may delete`) });
++
++ /** A person's control request, judged by Space's own roles exactly as an API call they made through a service. */
++ async function decide(ctx, viewer, request) {
++ const parsed = contracts.resources.parse(request.resource || '');
++ if (!parsed || parsed.service !== SERVICE || !TYPES[parsed.type] || !TYPES[parsed.type].test(parsed.id)) return unknown(request);
++ if (request.action !== 'delete') {
++ return result(request, 'refused', { problem: problem(422, 'control.unsupported_action', `a ${parsed.type} 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') });
++ }
++ if (parsed.project_id !== request.project_id) return unknown(request);
++ const as = { kind: 'service', service: viewer.service, claims: viewer.claims, subject: person.id, env: viewer.env, staff: false };
++ try {
++ // Load with the role deleting needs (maintain): a non-member is a 404 there, a lesser role a 403.
++ const loaded = parsed.type === 'site' ? await ctx.sites.load(as, parsed.id, 'maintain')
++ : parsed.type === 'deploy' ? await ctx.deploys.load(as, parsed.id, 'maintain')
++ : await ctx.domains.load(as, parsed.id, 'maintain');
++ const row = loaded[parsed.type];
++ if (!row || row.project_id !== parsed.project_id) return unknown(request);
++ if (request.dry_run) {
++ if (parsed.type !== 'domain') await ctx.takedowns.assertDeletable(as, { site: loaded.site || row });
++ if (parsed.type === 'deploy' && loaded.site && loaded.site.active_deploy_id === row.id) throw new ApiError(409, 'deploy.active', 'this deploy is being served: activate another one first');
++ if (parsed.type === 'domain' && row.kind === 'default') throw new ApiError(409, 'domain.default', 'the default domain goes away only with the site');
++ return result(request, 'done', { result: { dry_run: true, would: `delete the ${parsed.type}` } });
++ }
++ const out = parsed.type === 'site' ? await ctx.sites.remove(as, parsed.id, { deploys: ctx.deploys })
++ : parsed.type === 'deploy' ? await ctx.deploys.remove(as, parsed.id)
++ : await ctx.domains.remove(as, parsed.id);
++ return result(request, 'done', { result: { status: 'deleted', changed: true, ...(out && out.objects_removed != null ? { objects_removed: out.objects_removed } : {}) } });
++ } catch (err) {
++ if (!(err instanceof ApiError)) throw err;
++ if (err.status === 404) return unknown(request);
++ return result(request, 'refused', { problem: problem(err.status, err.code, err.message) });
++ }
++ }
++
++ /** First-party only: a person holds no service capability (as at the resource index). */
++ function firstParty(req, res, next) {
++ const v = req.viewer;
++ if (v && v.kind === 'service' && String(v.service || '').startsWith('svc:')) return next();
++ if (!v || v.kind === 'anonymous') return contracts.http.sendProblem(res, 401, 'auth.required', { detail: 'control requests come from first-party services: present a service token for audience openvibe.space', ctx: req.ov });
++ return contracts.http.sendProblem(res, 403, 'capability.denied', { detail: `${CAPABILITY} is first-party and requires a service token`, ctx: req.ov });
++ }
++
++ r.post('/control', firstParty, guard, json, async (req, res, next) => {
++ try {
++ res.set('Cache-Control', 'no-store');
++ const ctx = req.app.locals.ctx;
++ const db = ctx.store.db;
++ 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), ctx: req.ov });
++ const caller = req.viewer.service;
++ const digest = digestOf(request);
++ const seen = await db.prepare('SELECT digest, response FROM space_control_log WHERE caller = ? AND project_id = ? AND idempotency_key = ?')
++ .get(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', ctx: req.ov });
++ return res.json(typeof seen.response === 'string' ? JSON.parse(seen.response) : seen.response);
++ }
++ const answer = await decide(ctx, req.viewer, request);
++ await db.prepare(`INSERT INTO space_control_log (caller, project_id, idempotency_key, digest, response, created_at)
++ VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING`).run(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/resource-control.test.js b/test/resource-control.test.js
nnew file mode 100644
iindex 0000000..b65ca88
---- /dev/null
++++ b/test/resource-control.test.js
@@@ -0,0 +1,94 @@
++'use strict';
++// Space's control API (ADR-048, plan T13 step 10; capability space.resource.control): OpenVibe.Services deletes a site,
++// deploy or domain for a person, judged by Space's own project roles exactly as the API's DELETE routes are.
++const assert = require('assert');
++const crypto = require('crypto');
++const { validate } = require('openvibe-contracts');
++const { boot, check, done } = require('./stageb/boot');
++
++(async () => {
++ const t = await boot();
++ try {
++ const alice = t.user('alice');
++ const dora = t.user('dora');
++ const eve = t.user('eve');
++ const project = (await t.project(alice, 'Control')).id;
++ const other = (await t.project(alice, 'Elsewhere')).id;
++ await t.api('PUT', `/api/v1/projects/${project}/members/${dora.subject}`, { as: alice, json: { role: 'deployer' } });
++ const services = t.network.serviceToken('services', ['space.resource.control']);
++ const ovrn = (type, id, prj = project) => `ovrn:space:${prj}:${type}/${id}`;
++ const ask = (type, id, who, extra = {}) => ({ action: 'delete', resource: ovrn(type, id), project_id: project,
++ idempotency_key: `k-${crypto.randomUUID()}`, on_behalf_of: { type: 'user', id: who.subject }, ...extra });
++ const control = async (body, as = services) => {
++ const r = await t.api('POST', '/api/v1/resources/control', { as, json: body });
++ return { status: r.status, body: r.text ? r.json() : null, text: r.text };
++ };
++ const valid = (body) => assert.deepStrictEqual(validate('common.resource-control-result@1', body).errors, [], JSON.stringify(body));
++ const domainsOf = async (siteId) => (await t.api('GET', `/api/v1/sites/${siteId}`, { as: alice })).json().domains;
++
++ await check('first-party service tokens with space.resource.control only', async () => {
++ const site = await t.site(alice, project, 'rc-gate');
++ const body = ask('site', site.id, alice, { dry_run: true });
++ assert.strictEqual((await control(body, null)).status, 401);
++ assert.strictEqual((await control(body, alice)).status, 403, 'a person holds no service capability');
++ assert.strictEqual((await control(body, t.network.serviceToken('services', ['space.resource.read']))).status, 403);
++ assert.strictEqual((await control({ action: 'delete' })).status, 400);
++ assert.strictEqual((await t.api('GET', `/api/v1/sites/${site.id}`, { as: alice })).status, 200, 'nothing was deleted');
++ });
++
++ await check('an owner deletes a site through it: it stops serving, and a repeat replays the answer', async () => {
++ const site = await t.site(alice, project, 'rc-site');
++ await t.deploy(alice, site.id, { 'index.html': 'hi' });
++ const body = ask('site', site.id, alice);
++ const first = await control(body);
++ assert.strictEqual(first.status, 200, first.text);
++ valid(first.body);
++ assert.deepStrictEqual([first.body.state, first.body.result.status, first.body.result.changed], ['done', 'deleted', true]);
++ assert.strictEqual((await t.api('GET', `/api/v1/sites/${site.id}`, { as: alice })).status, 404, 'the site is gone');
++ assert.deepStrictEqual((await control(body)).body, first.body, 'the same key answers the stored result');
++ const conflict = await control({ ...body, dry_run: true });
++ assert.deepStrictEqual([conflict.status, conflict.body.code], [409, 'control.idempotency_conflict']);
++ });
++
++ await check('Space\'s own rules decide: a deployer may not, an outsider is not told it exists, the live deploy and the default domain stay', async () => {
++ const site = await t.site(alice, project, 'rc-rules');
++ const live = await t.deploy(alice, site.id, { 'index.html': 'v1' });
++ const asDeployer = await control(ask('site', site.id, dora));
++ valid(asDeployer.body);
++ assert.strictEqual(asDeployer.body.state, 'refused', 'deleting needs maintainer or owner');
++ assert.notStrictEqual(asDeployer.body.problem.code, 'resources.unknown_resource', 'a member learns why');
++ const outsider = await control(ask('site', site.id, eve));
++ assert.deepStrictEqual([outsider.body.state, outsider.body.problem.code], ['refused', 'resources.unknown_resource']);
++ const liveDeploy = await control(ask('deploy', live.id, alice));
++ assert.strictEqual(liveDeploy.body.problem.code, 'deploy.active');
++ const dryLive = await control(ask('deploy', live.id, alice, { dry_run: true }));
++ assert.strictEqual(dryLive.body.problem.code, 'deploy.active', 'a dry run applies the same rule');
++ const older = live;
++ await t.deploy(alice, site.id, { 'index.html': 'v2' });
++ const gone = await control(ask('deploy', older.id, alice));
++ assert.deepStrictEqual([gone.body.state, gone.body.result.status], ['done', 'deleted'], 'a deploy no longer served can go');
++ const def = (await domainsOf(site.id)).find((d) => d.kind === 'default');
++ assert.strictEqual((await control(ask('domain', def.id, alice))).body.problem.code, 'domain.default');
++ const custom = (await t.api('POST', `/api/v1/sites/${site.id}/domains`, { as: alice, json: { hostname: 'rc.example.org' } })).json().domain;
++ const dry = await control(ask('domain', custom.id, alice, { dry_run: true }));
++ assert.deepStrictEqual([dry.body.state, dry.body.result.dry_run], ['done', true]);
++ assert.ok((await domainsOf(site.id)).some((d) => d.id === custom.id), 'a dry run applies nothing');
++ assert.strictEqual((await control(ask('domain', custom.id, alice))).body.result.status, 'deleted');
++ assert.ok(!(await domainsOf(site.id)).some((d) => d.id === custom.id));
++ });
++
++ await check('only delete, only Space\'s kinds, only inside the resource\'s own project', async () => {
++ const site = await t.site(alice, other, 'rc-other');
++ const elsewhere = await control({ ...ask('site', site.id, alice), resource: ovrn('site', site.id, project) });
++ assert.strictEqual(elsewhere.body.problem.code, 'resources.unknown_resource', 'a site of another project is not found here');
++ const own = await t.site(alice, project, 'rc-kinds');
++ const suspend = await control({ ...ask('site', own.id, alice), action: 'suspend' });
++ valid(suspend.body);
++ assert.strictEqual(suspend.body.problem.code, 'control.unsupported_action');
++ const media = await control({ ...ask('site', own.id, alice), resource: `ovrn:media:${project}:object/med_${'0'.repeat(26)}` });
++ assert.strictEqual(media.body.problem.code, 'resources.unknown_resource');
++ assert.strictEqual((await t.api('GET', `/api/v1/sites/${own.id}`, { as: alice })).status, 200);
++ });
++ } finally { await t.close(); }
++ done();
++})().catch((err) => { console.error(err); process.exit(1); });