openvibers/openvibe.bot Commits

Resource index: bot for OpenVibe.Services (bot.resource.read) (#68)

OP OpenVibers committed 10 Oct 2026 · 9e1871a773 · 6 files, +188 −4

Plan T13 step 8 phase 2 (ADR-048): Bot's authority resource index for OpenVibe.Services, against `bot.resource.read` (openvibe-contracts 0.129.0, planned).

- `GET /api/v1/resources?project=&kind=&cursor=&limit=` returns a common.resource-list-result@1 page of `bot.robot` summaries: robots, named by the robot's name, owner its owner_subject, state `estopped` when the e-stop is latched, else `ready`. It has an opaque keyset cursor (Host's `[kind, id]` form), a default of 100 rows and a maximum of 1000. A bad `project` is a 400, and another kind gives an empty page.
- `GET /api/v1/resources/:ovrn` returns one summary, or 404. Robots are person-owned (Bot holds no projects), so there is no OVRN and every name is a 404, like Network's node principals.
- Service tokens carrying `bot.resource.read` only, through Bot's API auth (`granted`) with service principals only. A person's token is refused even where other routes accept one.
- Summaries carry id, kind, service, project/OVRN where they apply, owner (only a `usr_` subject), name, state and timestamps. Nothing else leaves the table.
- The public vhost 404s `/api/v1/resources` and below; Services calls 127.0.0.1:4630 (vhost reinstalled after deploy). STATUS.json lists the capability, which OpenVibe.Contracts' bot-manifest test compares against.

Services starts reading it once a Contracts release makes the capability active and Network grants the `services` principal.

`ov test` (files covering the change): 16 passed.

Changes

ddiff --git a/README.md b/README.md
iindex 7377eb9..c897213 100644
---- a/README.md
++++ b/README.md
@@@ -75,6 +75,9 @@ per export and deletion in `account_data_events` (migration 0008).
| `bot.robot.control` | the e-stop set and clear (clear is owner-only), the control gate and `POST /robots/:id/commands` (so a bound channel's chat can forward a command over HTTP instead of a hardware socket) |
| `bot.device.connect` | rotate and revoke a device credential |
| `bot.job.dispatch` | the internal Run → Bot jobs API (`POST /jobs`, `POST /jobs/:id/cancel`, `GET /jobs/:id`); services only |
++| `bot.resource.read` | the internal authority index (`GET /api/v1/resources`, `GET /api/v1/resources/:ovrn`); services only |
++
++The authority index lists `bot.robot` summaries for OpenVibe.Services. A robot belongs to a person, so a project filter returns an empty page and individual OVRN lookups return 404. Services read it over loopback with `bot.resource.read`.
A person acts on their own robots with a Network user token; a service acts with a service token plus the capability, for the subject it names. The REST routes are in `docs/protocol.md` §3 and the two WebSockets in §§1–2.
ddiff --git a/STATUS.json b/STATUS.json
iindex f10fe8c..410b156 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -18,7 +18,8 @@
"bot.robot.manage",
"bot.robot.control",
"bot.device.connect",
-- "bot.job.dispatch"
++ "bot.job.dispatch",
++ "bot.resource.read"
],
"events": [
"bot.robot.online",
ddiff --git a/deploy/nginx/openvibe.bot.conf b/deploy/nginx/openvibe.bot.conf
iindex 02e5fa9..c4d475f 100644
---- a/deploy/nginx/openvibe.bot.conf
++++ b/deploy/nginx/openvibe.bot.conf
@@@ -149,9 +149,11 @@ server {
proxy_pass http://127.0.0.1:4630;
}
-- # ── Health and readiness: host-local probes only ────────
-- # The app treats /api/health and /api/ready as loopback-only (README); keep them off the public
-- # vhost so a probe uses port 4630 directly. Exact matches outrank the /api/ prefix below.
++ # ── Internal API: host-local callers only ────────
++ # OpenVibe.Services reads the resource index, and probes read health and readiness, on port 4630 directly.
++ # Exact matches and the resources prefix outrank the public /api/ location below.
++ location = /api/v1/resources { return 404; }
++ location ^~ /api/v1/resources/ { return 404; }
location = /api/health { return 404; }
location = /api/ready { return 404; }
ddiff --git a/server/api/resource-index.js b/server/api/resource-index.js
nnew file mode 100644
iindex 0000000..3f8fb8b
---- /dev/null
++++ b/server/api/resource-index.js
@@@ -0,0 +1,82 @@
++'use strict';
++
++/** Bot's authority resource index for OpenVibe.Services (ADR-048). Robots are person-owned. */
++const express = require('express');
++const contracts = require('openvibe-contracts');
++const { fail } = require('../util');
++
++const SERVICE = 'bot';
++const KIND = 'bot.robot';
++const CAPABILITY = 'bot.resource.read';
++const PROJECT_ID_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
++const USER_SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
++const DEFAULT_LIMIT = 100;
++const MAX_LIMIT = 1000;
++
++/** Only the public fields in common.resource-summary@1. Person-owned robots have no OVRN. */
++function robotSummary(row) {
++ const summary = {
++ id: row.id, kind: KIND, service: SERVICE,
++ name: row.name, state: row.estop_latched ? 'estopped' : 'ready',
++ created_at: new Date(row.created_at).toISOString(),
++ updated_at: new Date(row.updated_at).toISOString(),
++ };
++ if (USER_SUBJECT_RE.test(String(row.owner_subject || ''))) summary.owner = { type: 'user', id: row.owner_subject };
++ const ovrn = contracts.resources.nameOf(summary);
++ if (ovrn) summary.ovrn = ovrn;
++ return summary;
++}
++
++const encodeCursor = (id) => Buffer.from(JSON.stringify([KIND, id])).toString('base64url');
++function decodeCursor(raw) {
++ let value;
++ try { value = JSON.parse(Buffer.from(String(raw), 'base64url').toString('utf8')); } catch { return null; }
++ return Array.isArray(value) && value.length === 2 && value[0] === KIND && /^rob_[0-9A-HJKMNP-TV-Z]{26}$/.test(value[1]) ? value[1] : null;
++}
++
++function filtersOf(query) {
++ const project = query.project === undefined || query.project === '' ? null : query.project;
++ if (project !== null && (typeof project !== 'string' || !PROJECT_ID_RE.test(project))) return { error: 'project must be a prj_ id' };
++ const kind = typeof query.kind === 'string' && query.kind !== '' ? query.kind : null;
++ let limit = DEFAULT_LIMIT;
++ if (query.limit !== undefined && query.limit !== '') {
++ if (typeof query.limit !== 'string' || !/^\d+$/.test(query.limit) || Number(query.limit) < 1 || Number(query.limit) > MAX_LIMIT) return { error: `limit must be an integer 1-${MAX_LIMIT}` };
++ limit = Number(query.limit);
++ }
++ let cursor = null;
++ if (query.cursor !== undefined && query.cursor !== '') {
++ cursor = typeof query.cursor === 'string' ? decodeCursor(query.cursor) : null;
++ if (!cursor) return { error: 'cursor is not one this index issued' };
++ }
++ return { project, kind, limit, cursor };
++}
++
++function router({ db, apiAuth }) {
++ const r = express.Router();
++ const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res)).catch(next);
++ const bad = (res, detail, ctx) => contracts.http.sendProblem(res, 400, 'resources.bad_query', { detail, ctx });
++ const unknown = (res, name, ctx) => contracts.http.sendProblem(res, 404, 'resources.unknown_resource', { detail: `no resource named ${name}`, ctx });
++
++ 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') fail(403, 'bot.forbidden', 'the resource index is a service-to-service call');
++ if (!apiAuth.granted(req.principal, CAPABILITY)) fail(403, 'capability.denied', `${CAPABILITY} not granted`);
++ next();
++ }
++
++ r.use(firstParty);
++ r.get('/', wrap(async (req, res) => {
++ const filters = filtersOf(req.query);
++ if (filters.error) return bad(res, filters.error, req.ov);
++ if (filters.project || (filters.kind && filters.kind !== KIND)) return res.json({ resources: [], next_cursor: null });
++ const rows = await db.many(`SELECT id, owner_subject, name, estop_latched, created_at, updated_at
++ FROM robots WHERE ($1::text IS NULL OR id > $1) ORDER BY id LIMIT $2`, [filters.cursor, filters.limit + 1]);
++ const resources = rows.slice(0, filters.limit).map(robotSummary);
++ const next_cursor = rows.length > filters.limit ? encodeCursor(resources[resources.length - 1].id) : null;
++ return res.json({ resources, next_cursor });
++ }));
++ r.get('/:ovrn', (req, res) => unknown(res, String(req.params.ovrn), req.ov));
++ return r;
++}
++
++module.exports = { router, robotSummary, filtersOf, encodeCursor, SERVICE, KIND, CAPABILITY, DEFAULT_LIMIT, MAX_LIMIT };
ddiff --git a/server/api/v1.js b/server/api/v1.js
iindex 1639a15..a485c83 100644
---- a/server/api/v1.js
++++ b/server/api/v1.js
@@@ -11,6 +11,7 @@
* | GET /profiles, /profiles/:id | — (public) | anyone |
* | POST /profiles/validate | — (public) | anyone (the profile validator) |
* | GET /kits, /kits/:id | — (public) | anyone |
++ * | GET /resources, /resources/:ovrn | bot.resource.read | — (services only, loopback) |
* | GET /robots | bot.robot.read | own robots (?owner= only themself) |
* | POST /robots | bot.robot.manage | the owner (new robot + pairing code)|
* | GET /robots/:id | bot.robot.read | a member (owner/operator/viewer) |
@@@ -38,6 +39,7 @@ const { http } = require('openvibe-contracts');
const { fail, userSubject, isRobotId, json } = require('../util');
const { getProfile, listProfiles, checkProfile } = require('../profiles');
const kits = require('../kits');
++const resourceIndex = require('./resource-index');
const CAP = {
read: 'bot.robot.read',
@@@ -50,6 +52,7 @@ const CAP = {
function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config }) {
const r = express.Router();
r.use(limits.reads('bot.read'));
++ 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 });
// The command route counts against the acted-for person, not the service token: a bound channel's chat
ddiff --git a/test/resource-index.test.js b/test/resource-index.test.js
nnew file mode 100644
iindex 0000000..accd768
---- /dev/null
++++ b/test/resource-index.test.js
@@@ -0,0 +1,93 @@
++'use strict';
++// The first-party authority index lists only public robot summaries, with stable id pagination.
++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('resource-alice');
++ const bob = t.network.newUser('resource-bob');
++ const first = (await t.robot(alice, { name: 'First robot' })).robot;
++ const second = (await t.robot(bob, { name: 'Second robot' })).robot;
++ const third = (await t.robot(alice, { name: 'Third robot' })).robot;
++ const auth = { cap: ['bot.resource.read'] };
++ const get = (path, options = auth) => t.call('GET', `/api/v1/resources${path}`, options);
++
++ await check('a page validates and reveals only the allowed summary fields', async () => {
++ await t.db.query('UPDATE robots SET estop_latched = true WHERE id = $1', [second.id]);
++ const response = await get('');
++ assert.strictEqual(response.status, 200, response.text);
++ assert.deepStrictEqual(contracts.validate('common.resource-list-result@1', response.json).errors, []);
++ assert.deepStrictEqual(response.json.resources.map((r) => r.id), [first.id, second.id, third.id].sort());
++ for (const summary of response.json.resources) {
++ assert.deepStrictEqual(Object.keys(summary).sort(), ['created_at', 'id', 'kind', 'name', 'owner', 'service', 'state', 'updated_at']);
++ assert.strictEqual(summary.kind, 'bot.robot');
++ assert.strictEqual(summary.service, 'bot');
++ assert.strictEqual(summary.ovrn, undefined);
++ assert.strictEqual(contracts.resources.nameOf(summary), null);
++ assert.ok(!Number.isNaN(Date.parse(summary.created_at)));
++ assert.ok(!Number.isNaN(Date.parse(summary.updated_at)));
++ }
++ assert.deepStrictEqual(response.json.resources.find((r) => r.id === second.id).owner, { type: 'user', id: bob.subject });
++ assert.strictEqual(response.json.resources.find((r) => r.id === second.id).state, 'estopped');
++ assert.strictEqual(response.json.resources.find((r) => r.id === first.id).state, 'ready');
++ });
++
++ await check('an id cursor walks each robot exactly once', async () => {
++ const ids = [];
++ let cursor = null;
++ do {
++ const response = await get(`?limit=1${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`);
++ assert.strictEqual(response.status, 200, response.text);
++ assert.deepStrictEqual(contracts.validate('common.resource-list-result@1', response.json).errors, []);
++ ids.push(...response.json.resources.map((r) => r.id));
++ cursor = response.json.next_cursor;
++ } while (cursor);
++ assert.deepStrictEqual(ids, [first.id, second.id, third.id].sort());
++ assert.strictEqual(new Set(ids).size, 3);
++ });
++
++ await check('project and kind filters are applied', async () => {
++ assert.deepStrictEqual((await get('?kind=bot.robot')).json.resources.map((r) => r.id), [first.id, second.id, third.id].sort());
++ for (const query of ['?kind=bot.unknown', '?project=prj_01J8ZQ4Y7N3M2K1H0G9F8E7D6C', '?kind=bot.robot&project=prj_01J8ZQ4Y7N3M2K1H0G9F8E7D6C']) {
++ assert.deepStrictEqual((await get(query)).json, { resources: [], next_cursor: null });
++ }
++ for (const query of ['?project=nope', '?limit=0', '?limit=1001', '?cursor=broken']) {
++ const response = await get(query);
++ assert.strictEqual(response.status, 400, response.text);
++ assert.strictEqual(response.json.code, 'resources.bad_query');
++ }
++ });
++
++ await check('a service token with bot.resource.read is required on both routes', async () => {
++ for (const path of ['', '/ovrn%3Abot%3Aprj_01J8ZQ4Y7N3M2K1H0G9F8E7D6C%3Arobot%2F' + first.id]) {
++ const anonymous = await get(path, { token: null });
++ assert.strictEqual(anonymous.status, 401);
++ assert.strictEqual(anonymous.json.code, 'bot.sign_in');
++ const person = await get(path, { user: alice });
++ assert.strictEqual(person.status, 403);
++ assert.strictEqual(person.json.code, 'bot.forbidden');
++ const denied = await get(path, { cap: ['bot.robot.read'] });
++ assert.strictEqual(denied.status, 403);
++ assert.strictEqual(denied.json.code, 'capability.denied');
++ }
++ });
++
++ await check('person-owned robots cannot be looked up by any OVRN', async () => {
++ for (const id of [first.id, second.id, third.id]) {
++ const name = `ovrn:bot:prj_01J8ZQ4Y7N3M2K1H0G9F8E7D6C:robot/${id}`;
++ const response = await get(`/${encodeURIComponent(name)}`);
++ assert.strictEqual(response.status, 404, response.text);
++ assert.strictEqual(response.json.code, 'resources.unknown_resource');
++ }
++ });
++
++ await check('removed robots leave the index', async () => {
++ await t.db.query('DELETE FROM robots WHERE id = $1', [third.id]);
++ assert.deepStrictEqual((await get('')).json.resources.map((r) => r.id), [first.id, second.id].sort());
++ });
++
++ await t.close();
++ done();
++})();