openvibers/openvibe.services Commits

Services: Actor tasks can be stopped and Media objects deleted from the console; each kind offers its own actions (contracts 0.169) (#58)

OP OpenVibers committed 30 days ago · e3a0f32dcf · 4 files, +54 −14

Plan T13 step 10 (contracts 0.169.0, Actor#60, Media#107, Network#187): Actor and Media take control requests too. The console now offers each kind's own actions.

**Console actions by kind** (`KIND_ACTIONS` in `server/http/portal.js`):
- `actor.task`: **Stop**, while the task is queued, running or verifying.
- `media.object`: **Delete…**, which asks first as before. A deleted object offers nothing.
- `events.subscription`: **Suspend**, or **Resume** when it is disabled. Before, a disabled subscription showed Suspend, and Delete was offered although Events refuses it.
- `watch.watch` and anything else: Suspend or Resume, and Delete…, as before.

**Who sees actions**
- An admin of the project acts on what they own, as before.
- Where the service allows it (Actor, Media), an admin also acts on anything of the project, including what its apps made: those resources have no person owner. The service still decides.

**Other changes**
- `stop` is accepted from the console form, and the result reads "Stopped".
- Contracts are pinned to v0.169.0. `control.test.js` now expects the four authorities that take control, and adds a check on the per-kind offers.

Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex e00c6fb..328ce48 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -16,7 +16,7 @@
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.168.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"
@@@ -890,9 +890,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.168.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.168.0",
-- "integrity": "sha512-v59y6iRgSSHozx98grx5CRwPHN8UCj0L88HHJpJVBw/Cnzd2QzvcQQZIPlOMqH9K1QEEYeFUIJMiCIMJez0b5A==",
++ "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 83a37a6..9feedd8 100644
---- a/package.json
++++ b/package.json
@@@ -27,7 +27,7 @@
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.168.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/http/portal.js b/server/http/portal.js
iindex ce96cc1..b10ce48 100644
---- a/server/http/portal.js
++++ b/server/http/portal.js
@@@ -38,6 +38,18 @@ const { usageBody, usageQuery } = require('../render/usage');
const { listQuery } = require('../api/v1');
const { ServiceError } = require('../util');
++// What the console offers on a resource (ADR-048 control), by kind. The owning service decides; a button it does not
++// take would only be refused. `admins`: the service lets a project's admins act on what its apps made, not only the
++// resource's own person (Actor and Media, contracts 0.169.0).
++const OPEN_TASK = new Set(['queued', 'running', 'verifying']);
++const KIND_ACTIONS = {
++ 'watch.watch': { actions: (x) => [x.state === 'paused' ? ['resume', 'Resume'] : ['suspend', 'Suspend'], ['delete', 'Delete…']] },
++ 'events.subscription': { actions: (x) => [x.state === 'disabled' ? ['resume', 'Resume'] : ['suspend', 'Suspend']] },
++ 'actor.task': { admins: true, actions: (x) => (OPEN_TASK.has(x.state) ? [['stop', 'Stop']] : []) },
++ 'media.object': { admins: true, actions: (x) => (x.state === 'deleted' ? [] : [['delete', 'Delete…']]) },
++ default: { actions: (x) => [x.state === 'paused' ? ['resume', 'Resume'] : ['suspend', 'Suspend'], ['delete', 'Delete…']] },
++};
++
const PRJ_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
const APP_RE = /^app_[0-9A-HJKMNP-TV-Z]{26}$/;
const CRD_RE = /^crd_[0-9A-HJKMNP-TV-Z]{26}$/;
@@@ -387,10 +399,18 @@ ${got.data.next_before ? html`<p><a href="/projects/${project.id}/audit?before=$
// requests (its manifest lists an active <id>.resource.control). The service decides; Services shows its answer.
const controllable = new Set(ctx.index.adapters.filter((a) => a.authority.control).map((a) => a.authority.id));
const c = controls ? csrf(req) : null;
++ // The page is an admin's (controls); they act on what they own, and on anything of the project at a service that
++ // lets a project's admins act (KIND_ACTIONS `admins`: what its apps made). The service decides either way.
++ const none = html`<span class="muted">—</span>`;
const actionsCell = (x) => {
-- if (!controls || !x.ovrn || !controllable.has(x.service) || !(x.owner && x.owner.type === 'user' && x.owner.id === me)) return html`<span class="muted">—</span>`;
-- const act = (action, label) => html`<form method="post" action="/projects/${controls.project.id}/resources/control" class="inline-form">${csrfField(c)}<input type="hidden" name="resource" value="${x.ovrn}"><input type="hidden" name="action" value="${action}"><button type="submit">${label}</button></form>`;
-- return html`${x.state === 'paused' ? act('resume', 'Resume') : act('suspend', 'Suspend')} ${act('delete', 'Delete…')}`;
++ if (!controls || !x.ovrn || !controllable.has(x.service)) return none;
++ const kind = KIND_ACTIONS[x.kind] || KIND_ACTIONS.default;
++ const mine = !!(x.owner && x.owner.type === 'user' && x.owner.id === me);
++ if (!mine && !kind.admins) return none;
++ const offered = kind.actions(x);
++ if (!offered.length) return none;
++ const act = ([action, label]) => html`<form method="post" action="/projects/${controls.project.id}/resources/control" class="inline-form">${csrfField(c)}<input type="hidden" name="resource" value="${x.ovrn}"><input type="hidden" name="action" value="${action}"><button type="submit">${label}</button></form>`;
++ return html`${offered.map((o, i) => html`${i ? ' ' : ''}${act(o)}`)}`;
};
const rows = result ? result.resources.map((x) => [
html`${x.name || x.id}<br><code class="small">${x.ovrn || x.id}</code>`,
@@@ -439,7 +459,7 @@ ${result && (filter.cursor || result.next_cursor) ? html`<p>${filter.cursor ? ht
// One control operation from the console (ADR-048): the person's own request, made through server/domain/control.js
// exactly as POST /api/v1/resources/control makes it (idempotency, the sensitive-action gate, the log and audit).
// Delete asks first. The service that holds the resource decides; its answer is shown as it is.
-- const CONSOLE_ACTIONS = { suspend: 'Suspended', resume: 'Resumed', delete: 'Deleted' };
++ const CONSOLE_ACTIONS = { suspend: 'Suspended', resume: 'Resumed', stop: 'Stopped', delete: 'Deleted' };
r.post('/:project/resources/control', idParams, B('services.resource.control'), form, guard, async (req, res) => {
const project = await loadProject(req, res);
if (!project) return;
@@@ -448,7 +468,7 @@ ${result && (filter.cursor || result.next_cursor) ? html`<p>${filter.cursor ? ht
const b = req.body || {};
const action = String(b.action || '');
const resource = String(b.resource || '').slice(0, 300);
-- if (!CONSOLE_ACTIONS[action]) return page(req, res, { title: 'Not done', body: html`<h1>Not done</h1><p>Suspend, resume or delete.</p><p><a href="${back}">Back</a></p>` }, 422);
++ if (!CONSOLE_ACTIONS[action]) return page(req, res, { title: 'Not done', body: html`<h1>Not done</h1><p>Suspend, resume, stop or delete.</p><p><a href="${back}">Back</a></p>` }, 422);
if (action === 'delete' && b.confirm !== 'yes') {
return page(req, res, {
title: 'Delete?', crumbs: [{ label: 'Projects', href: '/projects' }, { label: project.name, href: back }, { label: 'Delete' }],
ddiff --git a/test/control.test.js b/test/control.test.js
iindex 0e85282..425d414 100644
---- a/test/control.test.js
++++ b/test/control.test.js
@@@ -88,10 +88,10 @@ function registryOf(list) {
const prj = ids.newId('project');
const object = resources.format({ service: 'media', project_id: prj, type: 'object', id: ids.newId('media') });
-- await check('only an authority with an active <id>.resource.control takes control requests (Watch 0.165, Events 0.168)', async () => {
++ await check('only an authority with an active <id>.resource.control takes control requests (Watch 0.165, Events 0.168, Actor and Media 0.169)', async () => {
const real = createAuthorities(t.config, { log: { warn() {} } });
assert.ok(real.size() > 0);
-- const TAKES = { watch: 'watch.resource.control', events: 'events.resource.control' };
++ const TAKES = { watch: 'watch.resource.control', events: 'events.resource.control', actor: 'actor.resource.control', media: 'media.resource.control' };
for (const a of real.list()) assert.strictEqual(a.control, TAKES[a.id] || null, `${a.id}: the control capability in the pinned contracts`);
});
@@@ -246,7 +246,7 @@ function registryOf(list) {
const page = await t.get(`/projects/${project}/resources`, { as: owner });
assert.strictEqual(page.status, 200);
assert.ok(page.text.includes('<th>Actions</th>') || page.text.includes('Actions'), 'an Actions column');
-- assert.ok(page.text.includes('value="suspend"') && page.text.includes('value="delete"'));
++ assert.ok(page.text.includes('value="delete"') && !page.text.includes('value="suspend"'), 'a media object takes delete only');
const seenBefore = media.seen.length;
const suspended = await t.get(`/projects/${project}/resources/control`, { as: owner, form: { resource: mine, action: 'suspend' } });
assert.strictEqual(suspended.status, 303, suspended.text.slice(0, 300));
@@@ -262,11 +262,31 @@ function registryOf(list) {
assert.strictEqual(deleted.status, 303);
assert.strictEqual(media.seen.at(-1).body.action, 'delete');
const theirs = await t.get(`/projects/${project}/resources`, { as: viewer });
-- assert.ok(!theirs.text.includes('value="suspend"'), 'a viewer gets no controls');
++ assert.ok(!theirs.text.includes('value="delete"'), 'a viewer gets no controls');
assert.strictEqual((await t.get(`/projects/${project}/resources/control`, { as: viewer, form: { resource: mine, action: 'suspend' } })).status, 403);
media.list([]);
});
++ await check('the console offers what each kind takes; a project admin acts on what its apps made where the service allows it', async () => {
++ const owner = t.network.addUser('kinds-owner');
++ const project = await t.project(owner, 'Kinds project');
++ const now = new Date().toISOString();
++ const row = (kind, state, extra = {}) => {
++ const [svc, type] = kind.split('.');
++ const id = ids.newId(svc === 'media' ? 'media' : 'task');
++ return { id, kind, service: 'media', project_id: project, name: `${kind} ${state}`, state, created_at: now, updated_at: now,
++ ovrn: resources.format({ service: 'media', project_id: project, type, id }), ...extra };
++ };
++ const appMade = row('media.object', 'ready');
++ const gone = row('media.object', 'deleted', { owner: { type: 'user', id: owner.subject } });
++ media.list([appMade, gone]);
++ const page = (await t.get(`/projects/${project}/resources`, { as: owner })).text;
++ const cell = (x) => page.slice(page.indexOf(x.ovrn), page.indexOf('</tr>', page.indexOf(x.ovrn)));
++ assert.ok(cell(appMade).includes('value="delete"'), 'an admin may delete what the project\'s app stored');
++ assert.ok(!cell(gone).includes('<button'), 'a deleted object offers nothing');
++ media.list([]);
++ });
++
await check('one audit row per accepted request (a replay too); neither table holds params or a token', async () => {
const before = (await audits()).length;
const k = key();