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)
Changes
@@@ -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).nnew file mode 100644@@@ -0,0 +1,12 @@++-- phase: expand++-- ADR-048 control operations (plan T13 step 10, server/registry/resource-control.js): the first answer to each++-- (caller, project, idempotency_key), returned again for a repeat so a retried control request never applies twice.++CREATE TABLE 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)++);@@@ -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",@@@ -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"@@@ -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 }));@@@ -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('.');nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };@@@ -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', () => {@@@ -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}`;nnew file mode 100644@@@ -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); });@@@ -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)',
@@ -55,8 +55,9 @@ Rate cards are typed in by a person from each provider's price page, with the pa
@@ -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) |
| `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` | `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 |
| `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:**
**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).
- **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).
new file mode 100644
new file mode 100644
@@ -0,0 +1,12 @@
@@ -0,0 +1,12 @@
+-- phase: expand
+-- ADR-048 control operations (plan T13 step 10, server/registry/resource-control.js): the first answer to each
+-- (caller, project, idempotency_key), returned again for a repeat so a retried control request never applies twice.
+CREATE TABLE 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)
+);
@@ -15,7 +15,7 @@
@@ -15,7 +15,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.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-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.3",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.3",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -871,9 +871,9 @@
@@ -871,9 +871,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.143.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.143.0",
- "integrity": "sha512-Cdbf+9RoPAUBibpM2zXQvtsiKMugb55dKubTt8pFZvAmxsLtGvX+zaC++yL1vOSub8FX3+GgqcGtoJS6tklrUg==",
+ "version": "0.169.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.169.0",
+ "integrity": "sha512-Py2z8UaKGvLzG3atuIAnJKnRe74Te9cJRTaxqFq6m4fov6gaMXeS6rn7lH0tSIo0qBGD5trrT618ARXnklkp+A==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -27,7 +27,7 @@
@@ -27,7 +27,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.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-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.3",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.3",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -66,7 +66,8 @@ async function createApp(opts = {}) {
@@ -66,7 +66,8 @@ async function createApp(opts = {}) {
});
});
const engine = createEngine({ config, s, adapters, stream, webhooks, now: s.now, log });
const engine = createEngine({ config, s, adapters, stream, webhooks, now: s.now, log });
const principal = createPrincipal({ config, keys });
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.
// 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;
// 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 = {}) {
@@ -151,6 +152,7 @@ async function createApp(opts = {}) {
// ── API ─────────────────────────────────────────────────
// ── 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 }) }));
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.
// 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/resources', resourceIndex.router(ctx));
app.use('/api/v1', createApi(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 }));
app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));
@@ -28,7 +28,7 @@ const { serviceAuth, capabilities, http, ids } = require('openvibe-contracts');
@@ -28,7 +28,7 @@ const { serviceAuth, capabilities, http, ids } = require('openvibe-contracts');
const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;
const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;
const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
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) {
function decodePayload(token) {
const parts = String(token || '').split('.');
const parts = String(token || '').split('.');
new file mode 100644
new file mode 100644
@@ -0,0 +1,37 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,105 @@
@@ -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 };
@@ -13,15 +13,16 @@ const contracts = require('openvibe-contracts');
@@ -13,15 +13,16 @@ const contracts = require('openvibe-contracts');
const configLib = require('../server/config');
const configLib = require('../server/config');
const { boot, check, done } = require('./helpers/boot');
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 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 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 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). */
/** `METHOD /api/v1/path` for every API and resource index route, with its capability guard (null: public). */
function codeRoutes() {
function codeRoutes() {
const out = new Map();
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)) {
for (const m of source.matchAll(/^\s*r\.(get|post|patch|delete)\('([^']+)'(.*)$/gm)) {
const guard = m[3].match(/requireCapability\('([a-z.]+)'\)/);
const guard = m[3].match(/requireCapability\('([a-z.]+)'\)/);
const read = m[3].match(/limits\.reads\('([a-z.]+)'\)/);
const read = m[3].match(/limits\.reads\('([a-z.]+)'\)/);
@@ -64,7 +65,7 @@ function codeRoutes() {
@@ -64,7 +65,7 @@ function codeRoutes() {
} finally { await t.close(); }
} 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());
assert.deepStrictEqual([...manifest.capabilities].sort(), [...CAPS].sort());
for (const id of CAPS) {
for (const id of CAPS) {
const c = contracts.capabilities.get(id);
const c = contracts.capabilities.get(id);
@@ -79,6 +80,7 @@ function codeRoutes() {
@@ -79,6 +80,7 @@ function codeRoutes() {
assert.deepStrictEqual([...listed].sort(), [...CAPS].sort(), 'principal.js accepts exactly Actor\'s capabilities');
assert.deepStrictEqual([...listed].sort(), [...CAPS].sort(), 'principal.js accepts exactly Actor\'s capabilities');
const appSrc = fs.readFileSync(path.join(__dirname, '..', 'server', 'app.js'), 'utf8');
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', 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', () => {
await check('every capability is implemented by routes that exist, and every route is some capability\'s', () => {
@@ -42,7 +42,7 @@ async function boot(opts = {}) {
@@ -42,7 +42,7 @@ async function boot(opts = {}) {
const { createStore } = require('../../server/db');
const { createStore } = require('../../server/db');
const testdb = await require('./db').testDb();
const testdb = await require('./db').testDb();
const store = createStore(testdb.db, { now: opts.now });
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();
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 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}`;
const base = `http://127.0.0.1:${server.address().port}`;
new file mode 100644
new file mode 100644
@@ -0,0 +1,114 @@
@@ -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); });
@@ -109,6 +109,7 @@ const SAME = { 'sec-fetch-site': 'same-origin' };
@@ -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/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/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/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/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/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)',
'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)',