openvibers/openvibe.bot Commits
Resource index: bot for OpenVibe.Services (bot.resource.read) (#68)
Changes
@@@ -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.@@@ -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",@@@ -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; }nnew file mode 100644@@@ -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 };@@@ -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 chatnnew file mode 100644@@@ -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();++})();
@@ -75,6 +75,9 @@ per export and deletion in `account_data_events` (migration 0008).
@@ -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.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.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.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.
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.
@@ -18,7 +18,8 @@
@@ -18,7 +18,8 @@
"bot.robot.manage",
"bot.robot.manage",
"bot.robot.control",
"bot.robot.control",
"bot.device.connect",
"bot.device.connect",
- "bot.job.dispatch"
+ "bot.job.dispatch",
+ "bot.resource.read"
],
],
"events": [
"events": [
"bot.robot.online",
"bot.robot.online",
@@ -149,9 +149,11 @@ server {
@@ -149,9 +149,11 @@ server {
proxy_pass http://127.0.0.1:4630;
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/health { return 404; }
location = /api/ready { return 404; }
location = /api/ready { return 404; }
new file mode 100644
new file mode 100644
@@ -0,0 +1,82 @@
@@ -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 };
@@ -11,6 +11,7 @@
@@ -11,6 +11,7 @@
* | GET /profiles, /profiles/:id | — (public) | anyone |
* | GET /profiles, /profiles/:id | — (public) | anyone |
* | POST /profiles/validate | — (public) | anyone (the profile validator) |
* | POST /profiles/validate | — (public) | anyone (the profile validator) |
* | GET /kits, /kits/:id | — (public) | anyone |
* | 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) |
* | GET /robots | bot.robot.read | own robots (?owner= only themself) |
* | POST /robots | bot.robot.manage | the owner (new robot + pairing code)|
* | POST /robots | bot.robot.manage | the owner (new robot + pairing code)|
* | GET /robots/:id | bot.robot.read | a member (owner/operator/viewer) |
* | GET /robots/:id | bot.robot.read | a member (owner/operator/viewer) |
@@ -38,6 +39,7 @@ const { http } = require('openvibe-contracts');
@@ -38,6 +39,7 @@ const { http } = require('openvibe-contracts');
const { fail, userSubject, isRobotId, json } = require('../util');
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 CAP = {
const CAP = {
read: 'bot.robot.read',
read: 'bot.robot.read',
@@ -50,6 +52,7 @@ const CAP = {
@@ -50,6 +52,7 @@ const CAP = {
function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config }) {
function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config }) {
const r = express.Router();
const r = express.Router();
r.use(limits.reads('bot.read'));
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 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 });
// The command route counts against the acted-for person, not the service token: a bound channel's chat
// The command route counts against the acted-for person, not the service token: a bound channel's chat
new file mode 100644
new file mode 100644
@@ -0,0 +1,93 @@
@@ -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();
+})();