openvibers/openvibe.bot Commits
A person's machines in the resource index (kind bot.machine, online or offline), after their robots on one cursor, so OpenVibe.Services lists them (#193)
Changes
@@@ -1,10 +1,14 @@'use strict';/**-- * Bot's authority resource index for OpenVibe.Services (ADR-048). Robots are person-owned.++ * Bot's authority resource index for OpenVibe.Services (ADR-048). Robots and machines are person-owned.* GET /api/v1/resources?project=&kind=&owner=&cursor=&limit=: `owner` (usr_…/agt_…) answers only that subject's-- * robots (openvibe-sdk 0.41 sends it for a person's own resources; a robot's owner is a person, so an agt_ owner-- * matches nothing). A robot is in no project, so a project filter matches nothing. A malformed filter is a 400.++ * (openvibe-sdk 0.41 sends it for a person's own resources; an owner is a person, so an agt_ owner matches nothing).++ * Neither is in a project, so a project filter matches nothing. A malformed filter is a 400.++ *++ * Kinds: bot.robot (rob_, state ready or estopped), then bot.machine (dev_: a person's own machine, the Desks plan's++ * hosts, state online or offline). A robot's device keeps no owner of its own and is its robot's, not listed here.++ * The cursor names the kind and id of the last one, so a page that ends among the robots goes on to the machines.*/const express = require('express');const contracts = require('openvibe-contracts');@@@ -12,6 +16,7 @@ const { fail } = require('../util');const SERVICE = 'bot';const KIND = 'bot.robot';++const MACHINE_KIND = 'bot.machine';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}$/;@@@ -33,11 +38,26 @@ function robotSummary(row) {return summary;}--const encodeCursor = (id) => Buffer.from(JSON.stringify([KIND, id])).toString('base64url');++/** A person's own machine (devices.owner_subject): online while its Node is connected. */++function machineSummary(row, online) {++ const summary = {++ id: row.id, kind: MACHINE_KIND, service: SERVICE,++ name: row.name || row.id, state: online ? 'online' : 'offline',++ 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 };++ return summary;++}++++const encodeCursor = (id, kind = KIND) => 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;++ if (!Array.isArray(value) || value.length !== 2) return null;++ if (value[0] === KIND && /^rob_[0-9A-HJKMNP-TV-Z]{26}$/.test(value[1])) return { kind: KIND, id: value[1] };++ if (value[0] === MACHINE_KIND && /^dev_[0-9A-HJKMNP-TV-Z]{26}$/.test(value[1])) return { kind: MACHINE_KIND, id: value[1] };++ return null;}function filtersOf(query) {@@@ -59,7 +79,7 @@ function filtersOf(query) {return { project, kind, owner, limit, cursor };}--function router({ db, apiAuth }) {++function router({ db, apiAuth, hub = null }) {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 });@@@ -76,17 +96,31 @@ function router({ db, apiAuth }) {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 });++ if (filters.project || (filters.kind && filters.kind !== KIND && filters.kind !== MACHINE_KIND)) return res.json({ resources: [], next_cursor: null });if (filters.owner && !USER_SUBJECT_RE.test(filters.owner)) 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) AND ($3::text IS NULL OR owner_subject = $3) ORDER BY id LIMIT $2`,-- [filters.cursor, filters.limit + 1, filters.owner]);-- const resources = rows.slice(0, filters.limit).map(robotSummary);-- const next_cursor = rows.length > filters.limit ? encodeCursor(resources[resources.length - 1].id) : null;++ const cur = filters.cursor;++ const out = [];++ if ((!filters.kind || filters.kind === KIND) && (!cur || cur.kind === KIND)) {++ 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) AND ($3::text IS NULL OR owner_subject = $3) ORDER BY id LIMIT $2`,++ [cur ? cur.id : null, filters.limit + 1, filters.owner]);++ out.push(...rows.map(robotSummary));++ }++ if ((!filters.kind || filters.kind === MACHINE_KIND) && out.length <= filters.limit) {++ const after = cur && cur.kind === MACHINE_KIND ? cur.id : null;++ const rows = await db.many(`SELECT id, owner_subject, name, created_at, updated_at++ FROM devices WHERE owner_subject IS NOT NULL AND revoked_at IS NULL AND ($1::text IS NULL OR id > $1)++ AND ($3::text IS NULL OR owner_subject = $3) ORDER BY id LIMIT $2`,++ [after, filters.limit + 1 - out.length, filters.owner]);++ out.push(...rows.map((row) => machineSummary(row, !!(hub && hub.isOnline(row.id)))));++ }++ const resources = out.slice(0, filters.limit);++ const last = resources[resources.length - 1];++ const next_cursor = out.length > filters.limit ? encodeCursor(last.id, last.kind) : 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 };++module.exports = { router, robotSummary, machineSummary, filtersOf, encodeCursor, SERVICE, KIND, MACHINE_KIND, CAPABILITY, DEFAULT_LIMIT, MAX_LIMIT };@@@ -78,7 +78,7 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, submr.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, hub }));const manage = limits('bot.robot.manage', { minute: 30, hour: 300 });const control = limits('bot.control', { minute: 120, hour: 1200 });const machineScreen = createMachineScreen({ domain, hub });@@@ -1,5 +1,5 @@'use strict';--// The first-party authority index lists only public robot summaries, with stable id pagination.++// The first-party authority index lists only public robot and machine summaries, with stable id pagination.const assert = require('assert');const contracts = require('openvibe-contracts');const { boot, check, done } = require('./helpers/app');@@@ -108,6 +108,38 @@ const { boot, check, done } = require('./helpers/app');assert.deepStrictEqual((await get('')).json.resources.map((r) => r.id), [first.id, second.id].sort());});++ await check('a person\'s machines follow their robots (bot.machine): online while connected, by kind and owner, one cursor', async () => {++ const principal = t.network.addNode({ owner: alice.subject, ref: `worker:${alice.subject}`, name: 'desk-pc' });++ const c = await t.ws('/device', { headers: { Authorization: `Bearer ${t.network.signNode(principal)}` } });++ const deviceId = (await c.waitFor((m) => m.type === 'hello')).device_id;++ const all = await get('');++ assert.deepStrictEqual(contracts.validate('common.resource-list-result@1', all.json).errors, []);++ assert.deepStrictEqual(all.json.resources.map((r) => [r.kind, r.id]), [...[first.id, second.id].sort().map((id) => ['bot.robot', id]), ['bot.machine', deviceId]], 'robots, then machines');++ const machine = all.json.resources.find((r) => r.id === deviceId);++ assert.deepStrictEqual([machine.state, machine.owner, machine.service], ['online', { type: 'user', id: alice.subject }, 'bot']);++ assert.deepStrictEqual((await get('?kind=bot.machine')).json.resources.map((r) => r.id), [deviceId]);++ assert.deepStrictEqual((await get('?kind=bot.robot')).json.resources.map((r) => r.kind), ['bot.robot', 'bot.robot']);++ assert.deepStrictEqual((await get(`?owner=${bob.subject}`)).json.resources.map((r) => r.id), [second.id], 'never another person\'s');++ const ids = [];++ let cursor = null;++ do {++ const page = await get(`?limit=2${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`);++ assert.strictEqual(page.status, 200, page.text);++ ids.push(...page.json.resources.map((r) => r.id));++ cursor = page.json.next_cursor;++ } while (cursor);++ assert.deepStrictEqual(ids, [...[first.id, second.id].sort(), deviceId], 'a page ending among the robots goes on to the machines');++ c.close();++ let state = 'online';++ for (let i = 0; i < 20 && state !== 'offline'; i++) {++ await new Promise((r) => setTimeout(r, 100));++ state = (await get('?kind=bot.machine')).json.resources[0].state;++ }++ assert.strictEqual(state, 'offline', 'offline once its Node goes');++ await t.db.query('UPDATE devices SET revoked_at = now() WHERE id = $1', [deviceId]);++ assert.deepStrictEqual((await get('?kind=bot.machine')).json.resources, [], 'a removed machine leaves the index');++ });++await t.close();done();})();
@@ -1,10 +1,14 @@
@@ -1,10 +1,14 @@
'use strict';
'use strict';
/**
/**
- * Bot's authority resource index for OpenVibe.Services (ADR-048). Robots are person-owned.
+ * Bot's authority resource index for OpenVibe.Services (ADR-048). Robots and machines are person-owned.
* GET /api/v1/resources?project=&kind=&owner=&cursor=&limit=: `owner` (usr_…/agt_…) answers only that subject's
* GET /api/v1/resources?project=&kind=&owner=&cursor=&limit=: `owner` (usr_…/agt_…) answers only that subject's
- * robots (openvibe-sdk 0.41 sends it for a person's own resources; a robot's owner is a person, so an agt_ owner
- * matches nothing). A robot is in no project, so a project filter matches nothing. A malformed filter is a 400.
+ * (openvibe-sdk 0.41 sends it for a person's own resources; an owner is a person, so an agt_ owner matches nothing).
+ * Neither is in a project, so a project filter matches nothing. A malformed filter is a 400.
+ *
+ * Kinds: bot.robot (rob_, state ready or estopped), then bot.machine (dev_: a person's own machine, the Desks plan's
+ * hosts, state online or offline). A robot's device keeps no owner of its own and is its robot's, not listed here.
+ * The cursor names the kind and id of the last one, so a page that ends among the robots goes on to the machines.
*/
*/
const express = require('express');
const express = require('express');
const contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
@@ -12,6 +16,7 @@ const { fail } = require('../util');
@@ -12,6 +16,7 @@ const { fail } = require('../util');
const SERVICE = 'bot';
const SERVICE = 'bot';
const KIND = 'bot.robot';
const KIND = 'bot.robot';
+const MACHINE_KIND = 'bot.machine';
const CAPABILITY = 'bot.resource.read';
const CAPABILITY = 'bot.resource.read';
const PROJECT_ID_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
const PROJECT_ID_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
const USER_SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const USER_SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
@@ -33,11 +38,26 @@ function robotSummary(row) {
@@ -33,11 +38,26 @@ function robotSummary(row) {
return summary;
return summary;
}
}
-const encodeCursor = (id) => Buffer.from(JSON.stringify([KIND, id])).toString('base64url');
+/** A person's own machine (devices.owner_subject): online while its Node is connected. */
+function machineSummary(row, online) {
+ const summary = {
+ id: row.id, kind: MACHINE_KIND, service: SERVICE,
+ name: row.name || row.id, state: online ? 'online' : 'offline',
+ 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 };
+ return summary;
+}
+
+const encodeCursor = (id, kind = KIND) => Buffer.from(JSON.stringify([kind, id])).toString('base64url');
function decodeCursor(raw) {
function decodeCursor(raw) {
let value;
let value;
try { value = JSON.parse(Buffer.from(String(raw), 'base64url').toString('utf8')); } catch { return null; }
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;
+ if (!Array.isArray(value) || value.length !== 2) return null;
+ if (value[0] === KIND && /^rob_[0-9A-HJKMNP-TV-Z]{26}$/.test(value[1])) return { kind: KIND, id: value[1] };
+ if (value[0] === MACHINE_KIND && /^dev_[0-9A-HJKMNP-TV-Z]{26}$/.test(value[1])) return { kind: MACHINE_KIND, id: value[1] };
+ return null;
}
}
function filtersOf(query) {
function filtersOf(query) {
@@ -59,7 +79,7 @@ function filtersOf(query) {
@@ -59,7 +79,7 @@ function filtersOf(query) {
return { project, kind, owner, limit, cursor };
return { project, kind, owner, limit, cursor };
}
}
-function router({ db, apiAuth }) {
+function router({ db, apiAuth, hub = null }) {
const r = express.Router();
const r = express.Router();
const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res)).catch(next);
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 bad = (res, detail, ctx) => contracts.http.sendProblem(res, 400, 'resources.bad_query', { detail, ctx });
@@ -76,17 +96,31 @@ function router({ db, apiAuth }) {
@@ -76,17 +96,31 @@ function router({ db, apiAuth }) {
r.get('/', wrap(async (req, res) => {
r.get('/', wrap(async (req, res) => {
const filters = filtersOf(req.query);
const filters = filtersOf(req.query);
if (filters.error) return bad(res, filters.error, req.ov);
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 });
+ if (filters.project || (filters.kind && filters.kind !== KIND && filters.kind !== MACHINE_KIND)) return res.json({ resources: [], next_cursor: null });
if (filters.owner && !USER_SUBJECT_RE.test(filters.owner)) return res.json({ resources: [], next_cursor: null });
if (filters.owner && !USER_SUBJECT_RE.test(filters.owner)) 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) AND ($3::text IS NULL OR owner_subject = $3) ORDER BY id LIMIT $2`,
- [filters.cursor, filters.limit + 1, filters.owner]);
- const resources = rows.slice(0, filters.limit).map(robotSummary);
- const next_cursor = rows.length > filters.limit ? encodeCursor(resources[resources.length - 1].id) : null;
+ const cur = filters.cursor;
+ const out = [];
+ if ((!filters.kind || filters.kind === KIND) && (!cur || cur.kind === KIND)) {
+ 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) AND ($3::text IS NULL OR owner_subject = $3) ORDER BY id LIMIT $2`,
+ [cur ? cur.id : null, filters.limit + 1, filters.owner]);
+ out.push(...rows.map(robotSummary));
+ }
+ if ((!filters.kind || filters.kind === MACHINE_KIND) && out.length <= filters.limit) {
+ const after = cur && cur.kind === MACHINE_KIND ? cur.id : null;
+ const rows = await db.many(`SELECT id, owner_subject, name, created_at, updated_at
+ FROM devices WHERE owner_subject IS NOT NULL AND revoked_at IS NULL AND ($1::text IS NULL OR id > $1)
+ AND ($3::text IS NULL OR owner_subject = $3) ORDER BY id LIMIT $2`,
+ [after, filters.limit + 1 - out.length, filters.owner]);
+ out.push(...rows.map((row) => machineSummary(row, !!(hub && hub.isOnline(row.id)))));
+ }
+ const resources = out.slice(0, filters.limit);
+ const last = resources[resources.length - 1];
+ const next_cursor = out.length > filters.limit ? encodeCursor(last.id, last.kind) : null;
return res.json({ resources, next_cursor });
return res.json({ resources, next_cursor });
}));
}));
r.get('/:ovrn', (req, res) => unknown(res, String(req.params.ovrn), req.ov));
r.get('/:ovrn', (req, res) => unknown(res, String(req.params.ovrn), req.ov));
return r;
return r;
}
}
-module.exports = { router, robotSummary, filtersOf, encodeCursor, SERVICE, KIND, CAPABILITY, DEFAULT_LIMIT, MAX_LIMIT };
+module.exports = { router, robotSummary, machineSummary, filtersOf, encodeCursor, SERVICE, KIND, MACHINE_KIND, CAPABILITY, DEFAULT_LIMIT, MAX_LIMIT };
@@ -78,7 +78,7 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
@@ -78,7 +78,7 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
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.
// 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/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, hub }));
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 });
const machineScreen = createMachineScreen({ domain, hub });
const machineScreen = createMachineScreen({ domain, hub });
@@ -1,5 +1,5 @@
@@ -1,5 +1,5 @@
'use strict';
'use strict';
-// The first-party authority index lists only public robot summaries, with stable id pagination.
+// The first-party authority index lists only public robot and machine summaries, with stable id pagination.
const assert = require('assert');
const assert = require('assert');
const contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
const { boot, check, done } = require('./helpers/app');
const { boot, check, done } = require('./helpers/app');
@@ -108,6 +108,38 @@ const { boot, check, done } = require('./helpers/app');
@@ -108,6 +108,38 @@ const { boot, check, done } = require('./helpers/app');
assert.deepStrictEqual((await get('')).json.resources.map((r) => r.id), [first.id, second.id].sort());
assert.deepStrictEqual((await get('')).json.resources.map((r) => r.id), [first.id, second.id].sort());
});
});
+ await check('a person\'s machines follow their robots (bot.machine): online while connected, by kind and owner, one cursor', async () => {
+ const principal = t.network.addNode({ owner: alice.subject, ref: `worker:${alice.subject}`, name: 'desk-pc' });
+ const c = await t.ws('/device', { headers: { Authorization: `Bearer ${t.network.signNode(principal)}` } });
+ const deviceId = (await c.waitFor((m) => m.type === 'hello')).device_id;
+ const all = await get('');
+ assert.deepStrictEqual(contracts.validate('common.resource-list-result@1', all.json).errors, []);
+ assert.deepStrictEqual(all.json.resources.map((r) => [r.kind, r.id]), [...[first.id, second.id].sort().map((id) => ['bot.robot', id]), ['bot.machine', deviceId]], 'robots, then machines');
+ const machine = all.json.resources.find((r) => r.id === deviceId);
+ assert.deepStrictEqual([machine.state, machine.owner, machine.service], ['online', { type: 'user', id: alice.subject }, 'bot']);
+ assert.deepStrictEqual((await get('?kind=bot.machine')).json.resources.map((r) => r.id), [deviceId]);
+ assert.deepStrictEqual((await get('?kind=bot.robot')).json.resources.map((r) => r.kind), ['bot.robot', 'bot.robot']);
+ assert.deepStrictEqual((await get(`?owner=${bob.subject}`)).json.resources.map((r) => r.id), [second.id], 'never another person\'s');
+ const ids = [];
+ let cursor = null;
+ do {
+ const page = await get(`?limit=2${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`);
+ assert.strictEqual(page.status, 200, page.text);
+ ids.push(...page.json.resources.map((r) => r.id));
+ cursor = page.json.next_cursor;
+ } while (cursor);
+ assert.deepStrictEqual(ids, [...[first.id, second.id].sort(), deviceId], 'a page ending among the robots goes on to the machines');
+ c.close();
+ let state = 'online';
+ for (let i = 0; i < 20 && state !== 'offline'; i++) {
+ await new Promise((r) => setTimeout(r, 100));
+ state = (await get('?kind=bot.machine')).json.resources[0].state;
+ }
+ assert.strictEqual(state, 'offline', 'offline once its Node goes');
+ await t.db.query('UPDATE devices SET revoked_at = now() WHERE id = $1', [deviceId]);
+ assert.deepStrictEqual((await get('?kind=bot.machine')).json.resources, [], 'a removed machine leaves the index');
+ });
+
await t.close();
await t.close();
done();
done();
})();
})();