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)

OP OpenVibers committed 17 days ago · 744a6d49e5 · 3 files, +81 −15

Changes

ddiff --git a/server/api/resource-index.js b/server/api/resource-index.js
iindex b589544..7fbb42c 100644
---- a/server/api/resource-index.js
++++ b/server/api/resource-index.js
@@@ -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 };
ddiff --git a/server/api/v1.js b/server/api/v1.js
iindex d36ba07..dcb6263 100644
---- a/server/api/v1.js
++++ b/server/api/v1.js
@@@ -78,7 +78,7 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
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, 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 });
ddiff --git a/test/resource-index.test.js b/test/resource-index.test.js
iindex 5938232..04fa60d 100644
---- a/test/resource-index.test.js
++++ b/test/resource-index.test.js
@@@ -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();
})();