openvibers/openvibe.bot Commits
Bot: a robot's owner stops and resumes it from OpenVibe.Services (bot.resource.control, contracts 0.176.0) (#107)
Changes
@@@ -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",nnew file mode 100644@@@ -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)++);@@@ -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",@@@ -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",nnew file mode 100644@@@ -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 };@@@ -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 });nnew file mode 100644@@@ -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); });
@@ -5,7 +5,7 @@
@@ -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",
"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,
"port": 4630,
"domain": "openvibe.bot (www -> apex; /device and /control WebSockets; /install)",
"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",
"sdk": "openvibe-sdk v0.44.0",
"shared": "openvibe-shared v4.1.3",
"shared": "openvibe-shared v4.1.3",
"packages": [
"packages": [
@@ -20,7 +20,8 @@
@@ -20,7 +20,8 @@
"bot.computer.control",
"bot.computer.control",
"bot.device.connect",
"bot.device.connect",
"bot.job.dispatch",
"bot.job.dispatch",
- "bot.resource.read"
+ "bot.resource.read",
+ "bot.resource.control"
],
],
"events": [
"events": [
"bot.robot.online",
"bot.robot.online",
new file mode 100644
new file mode 100644
@@ -0,0 +1,12 @@
@@ -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)
+);
@@ -13,7 +13,7 @@
@@ -13,7 +13,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"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-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",
@@ -845,9 +845,9 @@
@@ -845,9 +845,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"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",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -25,7 +25,7 @@
@@ -25,7 +25,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"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-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",
new file mode 100644
new file mode 100644
@@ -0,0 +1,105 @@
@@ -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 };
@@ -41,6 +41,7 @@ const { fail, userSubject, isRobotId, json } = require('../util');
@@ -41,6 +41,7 @@ const { fail, userSubject, isRobotId, json } = require('../util');
const { getProfile, listProfiles, checkProfile } = require('../profiles');
const { getProfile, listProfiles, checkProfile } = require('../profiles');
const kits = require('../kits');
const kits = require('../kits');
const resourceIndex = require('./resource-index');
const resourceIndex = require('./resource-index');
+const resourceControl = require('./resource-control');
const CAP = {
const CAP = {
read: 'bot.robot.read',
read: 'bot.robot.read',
@@ -65,6 +66,8 @@ const MACHINE_SERVICES = {
@@ -65,6 +66,8 @@ const MACHINE_SERVICES = {
function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, submissions = null, routines = null, webhooks = null }) {
function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, submissions = null, routines = null, webhooks = null }) {
const r = express.Router();
const r = express.Router();
r.use(limits.reads('bot.read'));
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 }));
r.use('/resources', resourceIndex.router({ db: domain.db, apiAuth }));
const manage = limits('bot.robot.manage', { minute: 30, hour: 300 });
const manage = limits('bot.robot.manage', { minute: 30, hour: 300 });
const control = limits('bot.control', { minute: 120, hour: 1200 });
const control = limits('bot.control', { minute: 120, hour: 1200 });
new file mode 100644
new file mode 100644
@@ -0,0 +1,81 @@
@@ -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); });