openvibers/openvibe.actor Commits

Actor: the control API — Services can stop a person's task, or a task of a project they administer (contracts 0.169) (#60)

OP OpenVibers committed 30 days ago · 2da776eb31 · 12 files, +286 −12

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

**`POST /api/v1/resources/control`** (`actor.resource.control`, first-party service tokens only; the public vhost already 404s `/api/v1/resources`):
- `stop` cancels an `actor.task` exactly as `POST /api/v1/tasks/:id/cancel` does.
  - A queued task ends at once (`done`).
  - A running task is aborted (`pending`, with `stopping: true`) and ends cancelled when its current step returns.
  - A task that already ended answers `done` with `changed: false`.
- Who may act: the person who asked for the task, or an admin or owner of its project. The project role is read from Network (`server/identity/projects.js`, `network.project.member.read`), so a project's admins can stop what its apps started. 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 `actor_control_log` (migration 0007, expand) and returned for a repeat; another request under the same key is a 409.
- Contracts are pinned to v0.169.0, and the README documents the route.

**Tests**
- `test/resource-control.test.js` (4): guards, a real running task stopped through it, the project-admin path, refusals.
- `contracts.test.js` scans the new route.
- The SSRF ratchet lists the new Network call.
- The full suite passes (30/30).

Changes

ddiff --git a/README.md b/README.md
iindex b74b0d2..48ec45c 100644
---- a/README.md
++++ b/README.md
@@@ -55,8 +55,9 @@ Rate cards are typed in by a person from each provider's price page, with the pa
| `POST /api/v1/route` | public | dry run: which agent and why (heuristic class; nothing runs or is charged) |
| `GET /api/v1/resources` | `actor.resource.read` (service token) | `common.resource-list-result@1` for `actor.task`; filters: `project`, `kind`, `cursor`, `limit` |
| `GET /api/v1/resources/:ovrn` | `actor.resource.read` (service token) | one `common.resource-summary@1` for a project-owned task |
++| `POST /api/v1/resources/control` | `actor.resource.control` (service token) | `common.resource-control-request@1` → `-result@1`: `stop` cancels a task (queued: done at once; running: pending until its step returns) |
--The resource index is for OpenVibe.Services on loopback and is blocked at the public nginx vhost. It lists task summaries under the `actor.task` kind; only a service token with `actor.resource.read` can read it. A task without a project has no OVRN and cannot be fetched by name.
++The resource index is for OpenVibe.Services on loopback and is blocked at the public nginx vhost. It lists task summaries under the `actor.task` kind; only a service token with `actor.resource.read` can read it. A task without a project has no OVRN and cannot be fetched by name. The control route acts for the person in `on_behalf_of`: the person who asked for the task, or an admin or owner of its project (asked of Network with `network.project.member.read`), so a project's admins can stop what its apps started. Repeats with the same `idempotency_key` return the first answer (`actor_control_log`).
**Who can call it:**
- **A person:** their Network token as a Bearer, or this site's session. A write made with the session must come from openvibe.actor itself (fetch metadata or Origin).
ddiff --git a/migrations/0007_control_log.sql b/migrations/0007_control_log.sql
nnew file mode 100644
iindex 0000000..1912480
---- /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 actor_control_log (
++ caller text COLLATE "C" NOT NULL, -- service:… 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 fc1a891..4a4d84e 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -15,7 +15,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.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"
@@@ -871,9 +871,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 a4aac53..c8ce271 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.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/app.js b/server/app.js
iindex 4876977..7d12660 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -66,7 +66,8 @@ async function createApp(opts = {}) {
});
const engine = createEngine({ config, s, adapters, stream, webhooks, now: s.now, log });
const principal = createPrincipal({ config, keys });
-- const ctx = { config, s, keys, sso, stream, adapters, engine, webhooks, principal, log };
++ const projects = opts.projects || require('./identity/projects').createProjects({ config });
++ const ctx = { config, s, keys, sso, stream, adapters, engine, webhooks, principal, projects, log };
// Account export and deletion (ADR-033, ./identity/account-data.js): the two tables that hold a person's rows.
// The sender posts to Network's internal export/deletion routes with this service's own client-credentials token;
@@@ -151,6 +152,7 @@ async function createApp(opts = {}) {
// ── API ─────────────────────────────────────────────────
app.use('/api/v1', rateLimit({ windowMs: 60_000, limit: Number(process.env.ACTOR_API_RATE_LIMIT_PER_MIN) || 240, standardHeaders: true, legacyHeaders: false, handler: (req, res) => contracts.http.sendProblem(res, 429, 'rate_limited', { detail: 'too many requests from this address; retry shortly', ctx: req.ov }) }));
// OpenVibe.Services reads Actor's task index through a first-party service token on loopback.
++ app.use('/api/v1/resources', require('./registry/resource-control').router(ctx));
app.use('/api/v1/resources', resourceIndex.router(ctx));
app.use('/api/v1', createApi(ctx));
app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));
ddiff --git a/server/http/principal.js b/server/http/principal.js
iindex da826af..ef28eec 100644
---- a/server/http/principal.js
++++ b/server/http/principal.js
@@@ -28,7 +28,7 @@ const { serviceAuth, capabilities, http, ids } = require('openvibe-contracts');
const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;
const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
--const CAPABILITIES = ['actor.task.create', 'actor.task.read', 'actor.task.list', 'actor.agent.read', 'actor.agent.manage', 'actor.agent.run', 'actor.resource.read'];
++const CAPABILITIES = ['actor.task.create', 'actor.task.read', 'actor.task.list', 'actor.agent.read', 'actor.agent.manage', 'actor.agent.run', 'actor.resource.read', 'actor.resource.control'];
function decodePayload(token) {
const parts = String(token || '').split('.');
ddiff --git a/server/identity/projects.js b/server/identity/projects.js
nnew file mode 100644
iindex 0000000..b274f3a
---- /dev/null
++++ b/server/identity/projects.js
@@@ -0,0 +1,37 @@
++'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
++ * Actor's own client-credentials token. The control API (server/registry/resource-control.js) uses it so a project's
++ * admins can stop the tasks its apps started.
++ *
++ * const projects = createProjects({ config });
++ * await projects.isAdmin(projectId, subjectId) → true | false; throws when Network cannot answer
++ */
++const { serviceAuth } = require('openvibe-contracts');
++
++const ADMIN_ROLES = new Set(['admin', 'owner']);
++
++function createProjects({ config, fetchImpl = globalThis.fetch, tokens: tokensOverride = null }) {
++ const enabled = !!(tokensOverride || config.oauth.clientSecret);
++ const tokens = !enabled ? null : tokensOverride || serviceAuth.createTokenClient({
++ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
++ audience: 'openvibe.network', scope: 'network.project.member.read', fetchImpl,
++ });
++
++ async function isAdmin(projectId, subjectId) {
++ if (!tokens) throw new Error('Actor has no Network client secret: project roles cannot be read');
++ const res = await fetchImpl(`${config.networkInternalUrl}/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, enabled };
++}
++
++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..03925ef
---- /dev/null
++++ b/server/registry/resource-control.js
@@@ -0,0 +1,105 @@
++'use strict';
++
++/**
++ * Actor's control API (ADR-048, plan T13 step 10; capability actor.resource.control): POST /api/v1/resources/control —
++ * one common.resource-control-request@1 from a first-party service (OpenVibe.Services) acting for the person in
++ * on_behalf_of, on an actor.task named by its OVRN. The answer is a common.resource-control-result@1 (200 for every
++ * outcome; a malformed request is a 400 problem).
++ *
++ * stop cancel the task, exactly as POST /api/v1/tasks/:id/cancel does: a queued task ends at once (done); a running
++ * one is aborted and ends cancelled when its current step returns (pending). Its cost so far is kept.
++ *
++ * Any other action is refused `control.unsupported_action` (a task is deleted only with its person's account data).
++ * The person who asked for the task may act on it, and so may an admin or owner of its project (asked of Network:
++ * server/identity/projects.js), which is how a project's admins stop the tasks its apps started. 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 actor_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 store = require('../tasks/store');
++const { asyncRouter } = require('../http/router');
++
++const SERVICE = 'actor';
++const CAPABILITY = 'actor.resource.control';
++const USER_SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
++const TASK_ID_RE = /^tsk_[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({ s, principal, engine, projects, now = () => Date.now() }) {
++ const r = asyncRouter();
++ 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 send = (req, res, status, code, detail) => contracts.http.sendProblem(res, status, code, { detail, ctx: req.ov });
++
++ /** The capability guard checks the token; this also excludes people and app or agent tokens. */
++ function firstParty(req, res, next) {
++ if (req.principal.kind === 'app' && req.principal.requester.startsWith('service:')) return next();
++ return send(req, res, 403, 'capability.denied', `${CAPABILITY} is first-party and requires a service token`);
++ }
++
++ async function decide(request) {
++ const parsed = contracts.resources.parse(request.resource || '');
++ if (!parsed || parsed.service !== SERVICE || parsed.type !== 'task' || !TASK_ID_RE.test(parsed.id)) {
++ return result(request, 'refused', { problem: problem(404, 'resources.unknown_resource', `no task named ${request.resource || request.resource_kind}`) });
++ }
++ if (request.action !== 'stop') {
++ return result(request, 'refused', { problem: problem(422, 'control.unsupported_action', `a task takes stop, 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 who asked for the task') });
++ }
++ const row = await store.getTask(s, parsed.id);
++ const inProject = !!row && row.project_id === parsed.project_id && parsed.project_id === request.project_id;
++ let allowed = inProject && row.requester === `user:${person.id}`;
++ if (inProject && !allowed) {
++ try { allowed = await projects.isAdmin(row.project_id, 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 task named ${request.resource} that this person may stop`) });
++ }
++ if (!store.OPEN.includes(row.state)) return result(request, 'done', { result: { status: row.state, changed: false } });
++ if (request.dry_run) return result(request, 'done', { result: { dry_run: true, would: row.state === 'queued' ? 'cancel the task before it starts' : 'stop the task: it ends cancelled when its current step returns' } });
++ const after = await engine.cancel(row.id, `user:${person.id}`);
++ if (store.OPEN.includes(after.state)) return result(request, 'pending', { result: { status: after.state, stopping: true } });
++ return result(request, 'done', { result: { status: after.state, changed: true } });
++ }
++
++ // Mounted beside the read index (server/registry/resource-index.js) on /api/v1/resources: only this one route.
++ const noStore = (req, res, next) => { res.set('Cache-Control', 'no-store'); next(); };
++ r.post('/control', noStore, principal.middleware, principal.requireCapability('actor.resource.control'), firstParty, json, async (req, res) => {
++ const request = req.body;
++ const check = contracts.resources.checkControlRequest(request || {});
++ if (!check.valid) return send(req, res, 400, 'control.bad_request', JSON.stringify(check.errors).slice(0, 500));
++ const caller = req.principal.requester;
++ const digest = digestOf(request);
++ const seen = await s.db.maybe('SELECT digest, response FROM actor_control_log WHERE caller = $1 AND project_id = $2 AND idempotency_key = $3',
++ [caller, request.project_id, request.idempotency_key]);
++ if (seen) {
++ if (seen.digest !== digest) return send(req, res, 409, 'control.idempotency_conflict', '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);
++ await s.db.query(`INSERT INTO actor_control_log (caller, project_id, idempotency_key, digest, response, created_at)
++ VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT DO NOTHING`, [caller, request.project_id, request.idempotency_key, digest, JSON.stringify(answer), now()]);
++ return res.json(answer);
++ });
++
++ return r;
++}
++
++module.exports = { router, CAPABILITY, digestOf };
ddiff --git a/test/contracts.test.js b/test/contracts.test.js
iindex bf458a4..2c91bf4 100644
---- a/test/contracts.test.js
++++ b/test/contracts.test.js
@@@ -13,15 +13,16 @@ const contracts = require('openvibe-contracts');
const configLib = require('../server/config');
const { boot, check, done } = require('./helpers/boot');
--const CAPS = ['actor.task.create', 'actor.task.read', 'actor.task.list', 'actor.agent.read', 'actor.resource.read', 'actor.agent.manage', 'actor.agent.run'];
++const CAPS = ['actor.task.create', 'actor.task.read', 'actor.task.list', 'actor.agent.read', 'actor.resource.read', 'actor.resource.control', 'actor.agent.manage', 'actor.agent.run'];
const INDEX_ROUTES = ['GET /api/v1/resources', 'GET /api/v1/resources/:ovrn'];
const API_SRC = fs.readFileSync(path.join(__dirname, '..', 'server', 'http', 'api.js'), 'utf8');
const INDEX_SRC = fs.readFileSync(path.join(__dirname, '..', 'server', 'registry', 'resource-index.js'), 'utf8');
++const CONTROL_SRC = fs.readFileSync(path.join(__dirname, '..', 'server', 'registry', 'resource-control.js'), 'utf8');
/** `METHOD /api/v1/path` for every API and resource index route, with its capability guard (null: public). */
function codeRoutes() {
const out = new Map();
-- for (const [source, prefix] of [[API_SRC, '/api/v1'], [INDEX_SRC, '/api/v1/resources']]) {
++ for (const [source, prefix] of [[API_SRC, '/api/v1'], [INDEX_SRC, '/api/v1/resources'], [CONTROL_SRC, '/api/v1/resources']]) {
for (const m of source.matchAll(/^\s*r\.(get|post|patch|delete)\('([^']+)'(.*)$/gm)) {
const guard = m[3].match(/requireCapability\('([a-z.]+)'\)/);
const read = m[3].match(/limits\.reads\('([a-z.]+)'\)/);
@@@ -64,7 +65,7 @@ function codeRoutes() {
} finally { await t.close(); }
});
-- await check('the manifest lists five capabilities, owned by actor, and the code guards all five', () => {
++ await check('the manifest lists the capabilities Actor owns, and the code guards each', () => {
assert.deepStrictEqual([...manifest.capabilities].sort(), [...CAPS].sort());
for (const id of CAPS) {
const c = contracts.capabilities.get(id);
@@@ -79,6 +80,7 @@ function codeRoutes() {
assert.deepStrictEqual([...listed].sort(), [...CAPS].sort(), 'principal.js accepts exactly Actor\'s capabilities');
const appSrc = fs.readFileSync(path.join(__dirname, '..', 'server', 'app.js'), 'utf8');
assert.match(appSrc, /app\.use\('\/api\/v1\/resources', resourceIndex\.router\(ctx\)\)/);
++ assert.match(appSrc, /app\.use\('\/api\/v1\/resources', require\('\.\/registry\/resource-control'\)\.router\(ctx\)\)/);
});
await check('every capability is implemented by routes that exist, and every route is some capability\'s', () => {
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex 86d49e5..d9b5b78 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -42,7 +42,7 @@ async function boot(opts = {}) {
const { createStore } = require('../../server/db');
const testdb = await require('./db').testDb();
const store = createStore(testdb.db, { now: opts.now });
-- const built = await createApp({ config, store, log, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true, adapters: opts.adapters, accountSend: opts.accountSend, webhookPost: opts.webhookPost, bot: opts.bot });
++ const built = await createApp({ config, store, log, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true, adapters: opts.adapters, accountSend: opts.accountSend, webhookPost: opts.webhookPost, bot: opts.bot, projects: opts.projects });
await built.ctx.keys.ensure();
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const base = `http://127.0.0.1:${server.address().port}`;
ddiff --git a/test/resource-control.test.js b/test/resource-control.test.js
nnew file mode 100644
iindex 0000000..bcdde2e
---- /dev/null
++++ b/test/resource-control.test.js
@@@ -0,0 +1,114 @@
++'use strict';
++// Actor's control API (ADR-048, plan T13 step 10): OpenVibe.Services stops a person's task, or a task of a project the
++// person administers, with a first-party service token carrying actor.resource.control.
++const assert = require('assert');
++const crypto = require('crypto');
++const { ids, serviceAuth, validate } = require('openvibe-contracts');
++const { boot, check, done } = require('./helpers/boot');
++
++const SAME = { 'sec-fetch-site': 'same-origin' };
++
++(async () => {
++ const admins = new Set();
++ let networkDown = false;
++ const projects = { isAdmin: async (project, subject) => { if (networkDown) throw new Error('down'); return admins.has(`${project}|${subject}`); } };
++ const t = await boot({ projects });
++ try {
++ const kim = t.network.addUser('kim');
++ const lee = t.network.addUser('lee');
++ const project = ids.newId('project');
++ const other = ids.newId('project');
++ const token = (cap, sub = 'svc:services') => {
++ const now = Math.floor(Date.now() / 1000);
++ return serviceAuth.signServiceToken({ iss: t.network.url, sub, actor_type: sub.startsWith('svc:') ? 'service' : 'app',
++ aud: ['openvibe.actor'], cap, iat: now, exp: now + 300, jti: crypto.randomUUID(),
++ ...(sub.startsWith('app:') ? { project_id: project, ns: [project], env: 'production' } : {}) }, t.network.privatePem);
++ };
++ const services = { bearer: token(['actor.resource.control']) };
++ const ovrn = (id, prj = project) => `ovrn:actor:${prj}:task/${id}`;
++ const control = async (body, auth = services) => {
++ const r = await t.get('/api/v1/resources/control', { ...auth, method: 'POST', json: body });
++ return { status: r.status, body: r.json(), text: r.text };
++ };
++ const ask = (id, who, extra = {}) => ({ action: 'stop', resource: ovrn(id), project_id: project, idempotency_key: `k-${crypto.randomUUID()}`,
++ on_behalf_of: { type: 'user', id: who.subject }, ...extra });
++ const ok = (body) => assert.deepStrictEqual(validate('common.resource-control-result@1', body).errors, [], JSON.stringify(body));
++ async function add(requester, state, prj = project) {
++ const id = ids.newId('task');
++ await t.ctx.s.db.query(`INSERT INTO tasks (id, requester, project_id, task, mode, budget_task, budget_day, state, created_at)
++ VALUES ($1, $2, $3, 'a task', 'balanced', 1, 1, $4, $5)`, [id, requester, prj, state, new Date().toISOString()]);
++ return id;
++ }
++ const stateOf = async (id) => (await t.ctx.s.db.maybe('SELECT state FROM tasks WHERE id = $1', [id])).state;
++
++ await check('only a service token with actor.resource.control may send control requests', async () => {
++ const id = await add(`user:${kim.subject}`, 'succeeded');
++ const body = ask(id, kim);
++ assert.strictEqual((await t.get('/api/v1/resources/control', { method: 'POST', json: body })).status, 401);
++ assert.strictEqual((await t.get('/api/v1/resources/control', { as: kim, method: 'POST', json: body, headers: SAME })).status, 403);
++ assert.strictEqual((await control(body, { bearer: token(['actor.resource.read']) })).status, 403);
++ assert.strictEqual((await control(body, { bearer: token(['actor.resource.control'], `app:${ids.newId('app')}`) })).status, 403);
++ assert.strictEqual((await control({ action: 'stop' })).status, 400, 'a malformed request is a 400 problem');
++ });
++
++ await check('the person stops their running task: pending, then it ends cancelled; a repeat replays the answer', async () => {
++ t.providers.reset();
++ t.providers.state.cls = 'lookup';
++ t.providers.state.runtime = 'hang';
++ const id = (await t.get('/api/v1/tasks', { as: kim, json: { task: 'MX of example.com please' }, headers: SAME })).json().id;
++ await t.ctx.s.db.query('UPDATE tasks SET project_id = $1 WHERE id = $2', [project, id]);
++ await t.waitFor(id, ['running']);
++ await new Promise((r) => setTimeout(r, 150));
++ const body = ask(id, kim);
++ const first = await control(body);
++ assert.strictEqual(first.status, 200, first.text);
++ ok(first.body);
++ assert.deepStrictEqual([first.body.state, first.body.result.stopping], ['pending', true]);
++ await t.waitFor(id, ['cancelled']);
++ const task = (await t.get(`/api/v1/tasks/${id}`, { as: kim })).json();
++ assert.strictEqual(task.cancel.requested_by.id, kim.subject, 'the cancel is recorded as the person\'s');
++ const again = await control(body);
++ assert.deepStrictEqual(again.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']);
++ const ended = await control(ask(id, kim));
++ ok(ended.body);
++ assert.deepStrictEqual([ended.body.state, ended.body.result], ['done', { status: 'cancelled', changed: false }]);
++ });
++
++ await check('an admin of the project stops a task its app started; anyone else is told it does not exist', async () => {
++ const id = await add(`app:${ids.newId('app')}`, 'queued');
++ const refused = await control(ask(id, lee));
++ ok(refused.body);
++ assert.deepStrictEqual([refused.body.state, refused.body.problem.code], ['refused', 'resources.unknown_resource']);
++ admins.add(`${project}|${lee.subject}`);
++ const dry = await control(ask(id, lee, { dry_run: true }));
++ ok(dry.body);
++ assert.deepStrictEqual([dry.body.state, dry.body.result.dry_run], ['done', true]);
++ assert.strictEqual(await stateOf(id), 'queued', 'a dry run applies nothing');
++ const mine = await add(`user:${kim.subject}`, 'running');
++ const adminOnPerson = await control(ask(mine, lee, { dry_run: true }));
++ assert.strictEqual(adminOnPerson.body.state, 'done', 'a project admin governs every task of the project');
++ networkDown = true;
++ const down = await control(ask(id, lee));
++ ok(down.body);
++ assert.deepStrictEqual([down.body.state, down.body.problem.code], ['failed', 'identity.unavailable']);
++ networkDown = false;
++ admins.clear();
++ });
++
++ await check('only stop, only an actor task, only inside its own project', async () => {
++ const id = await add(`user:${kim.subject}`, 'running');
++ const del = await control({ ...ask(id, kim), action: 'delete' });
++ ok(del.body);
++ assert.deepStrictEqual([del.body.state, del.body.problem.code], ['refused', 'control.unsupported_action']);
++ const elsewhere = await add(`user:${kim.subject}`, 'running', other);
++ const wrong = await control({ ...ask(elsewhere, kim), resource: ovrn(elsewhere, project) });
++ assert.strictEqual(wrong.body.problem.code, 'resources.unknown_resource', 'a task of another project is not found here');
++ const media = await control({ ...ask(id, kim), resource: `ovrn:media:${project}:object/med_${'0'.repeat(26)}` });
++ assert.strictEqual(media.body.problem.code, 'resources.unknown_resource');
++ assert.strictEqual(await stateOf(id), 'running');
++ });
++ } finally { await t.close(); }
++ done();
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex fb059ec..4337bba 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -109,6 +109,7 @@ const SAME = { 'sec-fetch-site': 'same-origin' };
'server/agents/codes.js': 'the configured OpenVibe.Codes base (ACTOR_CODES_URL) with Actor\'s own service token; the subject must be usr_<ULID> and every task id tsk_<ULID> before it goes in a path; the repository named in the task is data for the person\'s own machine, never fetched here (test/codes.test.js)',
'server/agents/computers.js': 'the configured OpenVibe.Bot base (ACTOR_BOT_URL) with Actor\'s own service token (bot.computer.control); the machine id must be dev_<ULID> and the subject usr_<ULID> before anything is sent; a path or program is data for the person\'s own machine, never fetched here (test/computers.test.js)',
'server/agents/robots.js': 'the configured OpenVibe.Bot base (ACTOR_BOT_URL) with Actor\'s own service token; the robot id must be rob_<ULID> and the subject usr_<ULID> before anything is sent (test/robots.test.js)',
++ 'server/identity/projects.js': 'Network\'s internal membership read (configured networkInternalUrl) with Actor\'s own service token (network.project.member.read); the project and subject ids are path-encoded and come from a parsed OVRN and a checked usr_ id (test/resource-control.test.js)',
'server/agents/tools.js': 'the configured OpenVibe.Tools and OpenVibe.Search bases; ids come from Tools\' catalog, a page URL goes to Tools as input',
'server/events-consumer.js': 'OpenVibe.Events: the configured events.url (ACTOR_EVENTS_URL), to create the two ADR-033 subscriptions at boot',
'server/net/webhook-post.js': 'a task\'s webhook URL (plan T17), the one caller-chosen address: https on 443/8443 to public addresses only, checked at creation and at send time through openvibe-shared/egress safeLookup, never a redirect (test/webhooks.test.js)',