openvibers/openvibe.bot Commits

Bot: a robot's owner stops and resumes it from OpenVibe.Services (bot.resource.control, contracts 0.176.0) (#107)

OP OpenVibers committed 29 days ago · 5f8cff0fa4 · 7 files, +209 −7

A robot's owner can stop and resume it from OpenVibe.Services ("Your resources"), through Bot's control API (contracts 0.176.0, ADR-048 amendment: person-owned resources).

- **`POST /api/v1/resources/control`** (`server/api/resource-control.js`, capability `bot.resource.control`, first-party service tokens only). It takes one `common.owned-resource-control-request@1` naming a `bot.robot` and its owner, and answers `common.resource-control-result@1` (no `resource`: robots have no OVRN):
  - `stop`: latch the remote e-stop, as `POST /robots/:id/estop`. The hub pushes it and `bot.estop.set` is announced. An agent may always stop.
  - `resume`: clear it, as `POST /robots/:id/estop/clear`, owner only. An agent's resume is refused `control.confirmation_needed` without the owner's confirmation (Services asks for it).
- **Refusals:**
  - another action: `control.unsupported_action`;
  - a robot of another person, or another kind: refused as unknown, never confirmed to exist;
  - a project-scoped request: 400 (Bot holds no project resources).
- **dry_run** explains without applying.
- **Idempotency:** the first answer per (caller, key) is kept in `bot_control_log` (migration 0016, expand). The same key with another request gets 409.
- **Mounting:** the control router sits before the index, which keeps `bot.resource.read`.
- **Pins and docs:** `openvibe-contracts` v0.174.0 → v0.176.0. STATUS.json lists `bot.resource.control` and the new contracts tag.

**Tests:** `test/resource-control.test.js`:
- stop: once, the repeat, the key conflict, a second stop with no change, and `bot.estop.set` announced once;
- resume: dry run, the agent refusal, clearing it;
- an agent's stop;
- refusals: another person's robot, a person acting for someone else, another action or kind, a project request;
- first-party only: a read-only service token, a person's own token and no token are all refused.

The full suite passes 40/41; the one failure was `docs.test.js` (the STATUS contracts tag), which passes since the fix.

Changes

ddiff --git a/STATUS.json b/STATUS.json
iindex 73f84bb..9828335 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -5,7 +5,7 @@
"runtime": "node 22, express 4, ws; PostgreSQL 18 via openvibe-sdk/db (pg, or embedded PGlite in tests); Valkey optional; no build step",
"port": 4630,
"domain": "openvibe.bot (www -> apex; /device and /control WebSockets; /install)",
-- "contracts": "openvibe-contracts v0.174.0",
++ "contracts": "openvibe-contracts v0.176.0",
"sdk": "openvibe-sdk v0.44.0",
"shared": "openvibe-shared v4.1.3",
"packages": [
@@@ -20,7 +20,8 @@
"bot.computer.control",
"bot.device.connect",
"bot.job.dispatch",
-- "bot.resource.read"
++ "bot.resource.read",
++ "bot.resource.control"
],
"events": [
"bot.robot.online",
ddiff --git a/migrations/0016_control_log.sql b/migrations/0016_control_log.sql
nnew file mode 100644
iindex 0000000..b769b7e
---- /dev/null
++++ b/migrations/0016_control_log.sql
@@@ -0,0 +1,12 @@
++-- phase: expand
++-- ADR-048 amendment 2026-10-15 (server/api/resource-control.js): the first answer to each control request
++-- OpenVibe.Services sent for a person's robot, per (caller, idempotency_key), so a retry is never applied twice.
++CREATE TABLE bot_control_log (
++ caller text COLLATE "C" NOT NULL, -- the service that asked (svc:services)
++ idempotency_key text COLLATE "C" NOT NULL,
++ owner_subject text COLLATE "C" NOT NULL, -- the robot's owner the request named
++ digest text COLLATE "C" NOT NULL, -- the request without its delivery fields
++ response jsonb NOT NULL,
++ created_at timestamptz NOT NULL,
++ PRIMARY KEY (caller, idempotency_key)
++);
ddiff --git a/package-lock.json b/package-lock.json
iindex 746ff95..e23e254 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -13,7 +13,7 @@
"dotenv": "^16.4.7",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.174.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.176.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",
@@@ -845,9 +845,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.174.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.174.0",
-- "integrity": "sha512-EN8iOkrAuOBCLP9sfnLw6HNz9152TNERk7tEY7upLpNoTGkbFPF6JXfrCNoqGDYU0T/HRz5XZaC7ImPAdr8fPw==",
++ "version": "0.176.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.176.0",
++ "integrity": "sha512-COLEoRbjs6he+Tb2LvazezNWvHzB6CEWflebrgEyMaTd1hhE5zWJAAL/mdvR0LOzVDAX6vb1GPdTw83AulLfCg==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex 0f0a8c1..e2f1e11 100644
---- a/package.json
++++ b/package.json
@@@ -25,7 +25,7 @@
"dotenv": "^16.4.7",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.174.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.176.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/api/resource-control.js b/server/api/resource-control.js
nnew file mode 100644
iindex 0000000..b71fcf3
---- /dev/null
++++ b/server/api/resource-control.js
@@@ -0,0 +1,105 @@
++'use strict';
++/**
++ * Bot's control API for person-owned robots (ADR-048 amendment 2026-10-15, step 8 phase 2; capability
++ * bot.resource.control): POST /api/v1/resources/control takes one common.owned-resource-control-request@1 from a
++ * first-party service (OpenVibe.Services) naming a bot.robot by its id and its owner, and answers a
++ * common.resource-control-result@1 (200 for every outcome, without `resource`: robots have no OVRN; a malformed request
++ * is a 400 problem).
++ *
++ * stop latch the robot's remote e-stop, as POST /robots/:id/estop: every actuator stops and nothing moves
++ * until it is cleared
++ * resume clear that e-stop, as POST /robots/:id/estop/clear (a local stop on the robot itself still holds). An
++ * agent's resume needs the owner's confirmation, which Services asks for before it sends one.
++ *
++ * Any other action is refused `control.unsupported_action`. Only the owner Bot holds may be named: a robot of another
++ * person is refused as unknown, never confirmed to exist. dry_run decides and explains without applying. The first
++ * answer per (caller, idempotency_key) is kept in bot_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 { fail } = require('../util');
++
++const CAPABILITY = 'bot.resource.control';
++const KIND = 'bot.robot';
++const ROBOT_RE = /^rob_[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({ domain, apiAuth, hub, now = () => Date.now() }) {
++ const r = express.Router();
++ const db = domain.db;
++ const json = express.json({ limit: '16kb' });
++ const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res)).catch(next);
++ const result = (request, state, extra = {}) => ({ action: request.action, state, at: new Date(now()).toISOString(), ...extra });
++ const problem = (status, code, detail) => ({ type: `https://openvibe.network/problems/${code}`, title: code, status, code, detail });
++
++ function firstParty(req, res, next) {
++ if (req.principal.kind === 'anonymous') fail(401, 'bot.sign_in', 'sign in to do that');
++ if (req.principal.kind !== 'service' || !/^svc:/.test(String(req.principal.sub || ''))) fail(403, 'capability.denied', `${CAPABILITY} is first-party`);
++ if (!apiAuth.granted(req.principal, CAPABILITY)) fail(403, 'capability.denied', `${CAPABILITY} not granted`);
++ next();
++ }
++
++ async function decide(request) {
++ if (request.owned.kind !== KIND || !ROBOT_RE.test(request.owned.id)) {
++ return result(request, 'refused', { problem: problem(404, 'resources.unknown_resource', `no robot ${request.owned.kind} ${request.owned.id}`) });
++ }
++ if (request.action !== 'stop' && request.action !== 'resume') {
++ return result(request, 'refused', { problem: problem(422, 'control.unsupported_action', `a robot takes stop and resume, not ${request.action}`) });
++ }
++ const robot = await domain.robots.get(request.owned.id);
++ if (!robot || robot.owner_subject !== request.owner.id) {
++ return result(request, 'refused', { problem: problem(404, 'resources.unknown_resource', `no robot ${request.owned.id} of this person`) });
++ }
++ const asker = request.on_behalf_of || { type: 'user', id: request.owner.id };
++ if (request.action === 'resume' && asker.type !== 'user' && !request.confirmation_id) {
++ return result(request, 'refused', { problem: problem(403, 'control.confirmation_needed', 'an agent resumes a robot only with its owner\'s confirmation') });
++ }
++ const latched = !!robot.estop_latched;
++ if (request.action === 'stop') {
++ if (latched) return result(request, 'done', { result: { estop: true, changed: false } });
++ if (request.dry_run) return result(request, 'done', { result: { dry_run: true, would: 'latch the robot\'s e-stop' } });
++ const by = asker.id;
++ await domain.estop.set(robot.id, { latched: true, by, principalKind: asker.type === 'user' ? 'user' : 'agent' });
++ if (hub) { await hub.pushEstop(robot.id, true, by); hub.broadcast(robot.id); }
++ return result(request, 'done', { result: { estop: true, changed: true } });
++ }
++ if (!latched) return result(request, 'done', { result: { estop: false, changed: false } });
++ if (request.dry_run) return result(request, 'done', { result: { dry_run: true, would: 'clear the robot\'s e-stop' } });
++ await domain.estop.clear(robot.id, robot.owner_subject);
++ if (hub) { await hub.pushEstop(robot.id, false, robot.owner_subject); hub.broadcast(robot.id); }
++ return result(request, 'done', { result: { estop: false, changed: true } });
++ }
++
++ r.post('/', firstParty, json, wrap(async (req, res) => {
++ const request = req.body;
++ if (!request || typeof request !== 'object' || request.owned === undefined) {
++ // Bot holds no project resources: a project-scoped request names nothing here.
++ return contracts.http.sendProblem(res, 400, 'control.bad_request', { detail: 'Bot\'s robots are person-owned: send common.owned-resource-control-request@1', ctx: req.ov });
++ }
++ const check = contracts.resources.checkControlRequest(request);
++ if (!check.valid) return contracts.http.sendProblem(res, 400, 'control.bad_request', { detail: JSON.stringify(check.errors).slice(0, 500), ctx: req.ov });
++ const caller = req.principal.sub;
++ const digest = digestOf(request);
++ const seen = await db.maybe('SELECT digest, response FROM bot_control_log WHERE caller = $1 AND idempotency_key = $2', [caller, request.idempotency_key]);
++ if (seen) {
++ if (seen.digest !== digest) return contracts.http.sendProblem(res, 409, 'control.idempotency_conflict', { detail: 'this idempotency_key was used for another request', ctx: req.ov });
++ return res.json(typeof seen.response === 'string' ? JSON.parse(seen.response) : seen.response);
++ }
++ const answer = await decide(request);
++ await db.query(`INSERT INTO bot_control_log (caller, idempotency_key, owner_subject, digest, response, created_at)
++ VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT DO NOTHING`, [caller, request.idempotency_key, request.owner.id, digest, JSON.stringify(answer), new Date(now()).toISOString()]);
++ return res.json(answer);
++ }));
++ return r;
++}
++
++module.exports = { router, CAPABILITY, digestOf };
ddiff --git a/server/api/v1.js b/server/api/v1.js
iindex 75a5b61..a98c99c 100644
---- a/server/api/v1.js
++++ b/server/api/v1.js
@@@ -41,6 +41,7 @@ const { fail, userSubject, isRobotId, json } = require('../util');
const { getProfile, listProfiles, checkProfile } = require('../profiles');
const kits = require('../kits');
const resourceIndex = require('./resource-index');
++const resourceControl = require('./resource-control');
const CAP = {
read: 'bot.robot.read',
@@@ -65,6 +66,8 @@ const MACHINE_SERVICES = {
function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, submissions = null, routines = null, webhooks = null }) {
const r = express.Router();
r.use(limits.reads('bot.read'));
++ // Control first (bot.resource.control), then the index (bot.resource.read) for everything else under /resources.
++ r.use('/resources/control', limits('bot.control', { minute: 120, hour: 1200 }), resourceControl.router({ domain, apiAuth, hub }));
r.use('/resources', resourceIndex.router({ db: domain.db, apiAuth }));
const manage = limits('bot.robot.manage', { minute: 30, hour: 300 });
const control = limits('bot.control', { minute: 120, hour: 1200 });
ddiff --git a/test/resource-control.test.js b/test/resource-control.test.js
nnew file mode 100644
iindex 0000000..6a7c7fa
---- /dev/null
++++ b/test/resource-control.test.js
@@@ -0,0 +1,81 @@
++'use strict';
++// ADR-048 amendment 2026-10-15: OpenVibe.Services stops and resumes a person's robot through Bot's control API
++// (bot.resource.control, common.owned-resource-control-request@1). Only the owner Bot holds may be named; a repeat
++// answers the first result; an agent's resume waits for the owner's confirmation; first-party only.
++const assert = require('assert');
++const contracts = require('openvibe-contracts');
++const { boot, check, done } = require('./helpers/app');
++
++(async () => {
++ const t = await boot();
++ const alice = t.network.newUser('control-alice');
++ const bob = t.network.newUser('control-bob');
++ const robot = (await t.robot(alice, { name: 'Alice robot' })).robot;
++ const auth = { cap: ['bot.resource.control'], sub: 'svc:services' };
++ const send = (body, options = auth) => t.call('POST', '/api/v1/resources/control', { ...options, body });
++ let n = 0;
++ const req = (action, over = {}) => ({
++ action, owned: { kind: 'bot.robot', id: robot.id }, owner: { type: 'user', id: alice.subject },
++ on_behalf_of: { type: 'user', id: alice.subject }, idempotency_key: `idem_robot_${++n}_${Date.now()}`, ...over,
++ });
++ const latched = async () => (await t.db.query('SELECT estop_latched FROM robots WHERE id = $1', [robot.id])).rows[0].estop_latched;
++ const valid = (body) => assert.ok(contracts.resources.checkControlResult(body).valid, JSON.stringify(contracts.resources.checkControlResult(body).errors));
++
++ await check('stop latches the e-stop; a repeat answers the first result; stop again changes nothing', async () => {
++ const first = req('stop');
++ let r = await send(first);
++ assert.strictEqual(r.status, 200, r.text);
++ valid(r.json);
++ assert.deepStrictEqual([r.json.state, r.json.result, r.json.resource], ['done', { estop: true, changed: true }, undefined]);
++ assert.strictEqual(await latched(), true);
++ const again = await send(first);
++ assert.deepStrictEqual(again.json, r.json, 'the same key answers the first result');
++ r = await send({ ...first, dry_run: true });
++ assert.deepStrictEqual([r.status, r.json.code], [409, 'control.idempotency_conflict'], 'the key reused with another request');
++ r = await send(req('stop'));
++ assert.deepStrictEqual(r.json.result, { estop: true, changed: false });
++ const events = (await t.db.query("SELECT envelope FROM bot_event_outbox WHERE envelope->>'event_type' = 'bot.estop.set' AND envelope->'subject'->>'id' = $1", [robot.id])).rows;
++ assert.strictEqual(events.length, 1, 'bot.estop.set is announced once');
++ });
++
++ await check('resume clears it (dry run first); an agent\'s resume needs the owner\'s confirmation', async () => {
++ let r = await send(req('resume', { dry_run: true }));
++ assert.deepStrictEqual([r.json.state, r.json.result.dry_run], ['done', true]);
++ assert.strictEqual(await latched(), true, 'a dry run applies nothing');
++ r = await send(req('resume', { on_behalf_of: { type: 'agent', id: 'agt_01J8ZQ4Y7N3M2K1H0G9F8E7D6C' } }));
++ assert.deepStrictEqual([r.json.state, r.json.problem.code], ['refused', 'control.confirmation_needed']);
++ assert.strictEqual(await latched(), true);
++ r = await send(req('resume'));
++ assert.deepStrictEqual([r.json.state, r.json.result], ['done', { estop: false, changed: true }]);
++ assert.strictEqual(await latched(), false);
++ r = await send(req('stop', { on_behalf_of: { type: 'agent', id: 'agt_01J8ZQ4Y7N3M2K1H0G9F8E7D6C' } }));
++ assert.deepStrictEqual(r.json.result, { estop: true, changed: true }, 'an agent may always stop it');
++ await send(req('resume'));
++ });
++
++ await check('only the owner Bot holds; unknown kinds and actions are refused', async () => {
++ let r = await send(req('stop', { owner: { type: 'user', id: bob.subject }, on_behalf_of: { type: 'user', id: bob.subject } }));
++ assert.deepStrictEqual([r.json.state, r.json.problem.code], ['refused', 'resources.unknown_resource'], 'another person\'s robot is not confirmed to exist');
++ assert.strictEqual(await latched(), false);
++ r = await send(req('stop', { on_behalf_of: { type: 'user', id: bob.subject } }));
++ assert.deepStrictEqual([r.status, r.json.code], [400, 'control.bad_request'], 'a person acts only on what they own');
++ r = await send(req('delete'));
++ assert.deepStrictEqual([r.json.state, r.json.problem.code], ['refused', 'control.unsupported_action']);
++ r = await send(req('stop', { owned: { kind: 'bot.device', id: 'dev_01J8ZQ4Y7N3M2K1H0G9F8E7D6C' } }));
++ assert.strictEqual(r.json.problem.code, 'resources.unknown_resource');
++ r = await send({ action: 'delete', resource: 'ovrn:bot:prj_01J8ZQ4Y7N3M2K1H0G9F8E7D6C:robot/rob_01J8ZQ4Y7N3M2K1H0G9F8E7D6C', project_id: 'prj_01J8ZQ4Y7N3M2K1H0G9F8E7D6C', idempotency_key: 'idem_project_1' });
++ assert.deepStrictEqual([r.status, r.json.code], [400, 'control.bad_request'], 'Bot holds no project resources');
++ });
++
++ await check('first-party only, with bot.resource.control', async () => {
++ let r = await send(req('stop'), { cap: ['bot.resource.read'], sub: 'svc:services' });
++ assert.strictEqual(r.status, 403);
++ r = await send(req('stop'), { user: alice });
++ assert.strictEqual(r.status, 403, 'a person\'s own token is not a first-party service');
++ r = await send(req('stop'), { token: null });
++ assert.strictEqual(r.status, 401);
++ assert.strictEqual(await latched(), false);
++ });
++
++ await done(t);
++})().catch((e) => { console.error(e); process.exit(1); });