openvibers/openvibe.bot Commits

The machines hub's side: the roles of a machine and its state for Network (plan L34) (#245)

OP OpenVibers committed 13 days ago · 44a6b98cbb · 13 files, +434 −17

* The machines hub's side: the roles of a machine and its state for Network (plan L34)

Bot stays the registry of the machines; the network's hub (OpenVibe.Network)
fronts it for the person and for the apps the person consented to.

- The catalogue of roles (server/machine-roles.js): the products' manifests
  declare them (Contracts 0.257.0's machineRoles), and the local copy keeps
  working until the pin moves. A role outside the catalogue is refused.
- The machine's roles (migration 0028) and its new name, for the hub.
- The hub's routes: GET /api/v1/machines/:id, PATCH /api/v1/machines/:id,
  PUT /api/v1/machines/:id/roles, POST /api/v1/machines/pairings and
  POST /api/v1/machines/:id/revoke, behind bot.machine.manage: the hub acts
  for the person it names in X-OV-Subject, and the machine must be that
  person's own or it is not there at all (404, never 403).
- The roles reach the machine in its config frame, so the Node turns them on
  at its next check-in.
- The machine's online state is reported to Network once per change.
- The machines page links to the hub: all your machines are on
  openvibe.network/machines.

* the machines hub test: the online report of a machine of its own

The check counted every online report Bot had made, so the report of the connection
above (a role change on the same machine) counted too and the check failed once the
two landed in a different order. The check now pairs a machine of its own and counts
the reports of that machine only.

* the machines hub's contracts are 0.258.0

main released 0.257.0 (Your AI), so the machines hub is the next release.

* openvibe-contracts v0.261.0 (was v0.223.0)

The machines hub contracts (a machine's roles and its state for Network) are released in v0.261.0. Bumps the pin, the lockfile and the version lines.

* only the network's own service may act for a person on their machines

The capability bot.machine.manage was enough: any service holding it could name
any person in X-OV-Subject and act as them on their machines. The hub is the
network's own service (svc:network), and only that one may list a person's
machines or manage one of them; anyone else with the capability is refused with
403 bot.forbidden.

* Merge remote-tracking branch 'origin/feat/machines-hub-bot' into feat/machines-hub-bot

* Merge remote-tracking branch 'origin/main' into pin/bot245

* the hub's contracts are 0.261.0, and the catalogue is the manifests'

The pin moved to Contracts 0.261.0, where the products' manifests carry the roles
they install, so the catalogue is their union and the local copy is only the
fallback for an older pin.

* Merge remote-tracking branch 'origin/feat/machines-hub-bot' into feat/machines-hub-bot

Changes

ddiff --git a/README.md b/README.md
iindex 7bd2724..ba7d76d 100644
---- a/README.md
++++ b/README.md
@@@ -43,6 +43,7 @@ Sitemap entries use the release commit time for these code-backed pages.
- A device (the machine that serves a robot) pairs with one pasted command, shown on the robot's pairing page: `curl -fsSL <BOT_INSTALLER_URL> | sh -s -- --robot rob_… --code XXXX-XXXX [--driver …]` (the Network form in `docs/protocol.md` §1). The `--driver` follows the robot's profile: `adeept` for `adeept.adr036`, `adeept-mecanum` for `adeept.adr036.mecanum`, `cozmo` for `cozmo`, `looi` for `looi` (the community's Generic LOOI driver, a Bluetooth bridge; OpenVibe.Node `docs/hardware-looi.md`); `ros2` for `ros2.diffdrive` and `ros2-holonomic` for `ros2.holonomic` (any ROS 2 robot on `cmd_vel`, through rosbridge or rclpy; OpenVibe.Node `plugins/ros2`); any other profile gets none (`server/domain/index.js` `driverForProfile`), the installer's dry-run `none`.
- **The one command on the robot** (`server/claims.js`, migration 0019): `curl -fsSL <BOT_INSTALLER_URL> | sh` with no robot and no code. The installer finds the kit on the machine (OpenVibe.Node `docs/install.md`) and starts a claim, `POST /api/v1/claims` (anyone, counted by address: a code like `KQ7M-2XRD`, a poll secret, 15 minutes). The owner enters the code at `/link` (signed in; the QR opens `/link?code=…`), sees what the machine found (shown escaped, it only pre-selects the model) and approves it with a name, a model the installer has a driver for (or the generic relay), who may drive, and **Show its camera on my OpenVibe.Live channel**, or denies it. Approving makes the robot exactly as Add robot does and keeps its pairing on the claim; the machine reads it with `GET /api/v1/claims/:id` and its `X-OV-Claim-Secret`, then pairs the usual way. Once it has paired (`domain.pairing.onPaired`) the pairing is dropped and the Live mirror is turned on if the owner asked. The approval page links to the owner's channel with `?bind_robot=rob_…`, where Live asks them to let the channel's chat drive it.
- **A person's own machine that runs their jobs** (plan T14/T16) serves no robot. `openvibe.bot/machines` lists the person's machines (online, the job classes they run, the robot they serve if any), adds one with a one-time command, `curl -fsSL <BOT_INSTALLER_URL> | sh -s -- --network <Network> --pairing pair_… --code XXXX-XXXX --worker` (a Network pairing with ref `worker:<owner>`, so the device keeps `owner_subject` and has no robot; migration 0013), and removes one (revoked here and on Network). Every live machine with a job worker, a robot's included, is reported to Network as a `user-owned` resource offer naming its owner (`server/jobs/offers.js`: one offer per device, `worker:<class>` per class, `up` while connected and `down` otherwise; the whole set as source `bot`, once a minute and two seconds after a device comes, goes or changes what it advertises). Run places a person's job only on that person's machines, and the dispatcher refuses any other (`bot.not_owner`).
++- **The machines hub** (plan L34, step M2; contracts 0.261.0). Bot stays the registry of the machines; OpenVibe.Network fronts it on `openvibe.network/machines` for the person and for the apps the person consented to. The hub's routes are `GET /api/v1/machines`, `GET /api/v1/machines/:id`, `POST /api/v1/machines/pairings` (a one-time code and the command to run), `PATCH /api/v1/machines/:id` (a new name), `PUT /api/v1/machines/:id/roles` and `POST /api/v1/machines/:id/revoke`, guarded by `bot.machine.manage`: the network's own service (`svc:network`, and only that one — the capability alone would let any service name any person) acts for the person it names in `X-OV-Subject`, and the machine must be that person's own or it is not there at all (404, never 403, so nobody can probe whose machine an id is). The roles (`devices.roles`, migration 0028) come from the catalogue in Contracts (`codes.worker`, `desks.host`, `ai.host`, `bot.robot`); a role outside the catalogue is refused (`422 bot.unknown_role`) and nothing is stored. The roles reach the machine in its `config` frame, so the Node turns them on at its next check-in. Each machine's online state is reported to Network (`POST /internal/machines/status`) once per change, and Network publishes it as `network.machine.online` and `network.machine.offline`.
- **Desks for OpenVibe.Run's sessions** (contracts 0.223.0, `bot.desk.session`, the Desks plan's D6; migration 0026). Run, with a service token naming the session's person in `X-OV-Subject`, leases one named browser desk per session (`bot.desk-lease@1`).
- `GET /api/v1/desk-hosts` lists where a desk may go for that person:
- their own machines where "Let my agent use this computer" is on (trust `user-owned`);
@@@ -171,6 +172,7 @@ 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.machine.ai` | OpenVibe.AI asks a person's own machine's model for them (kind `ai` only), and lists their machines whose owner turned on "Let my AI use this computer's models" |
++| `bot.machine.manage` | the machines hub (OpenVibe.Network) acts for a person: `GET /machines`, `GET /machines/:id`, `POST /machines/pairings`, `PATCH /machines/:id`, `PUT /machines/:id/roles` and `POST /machines/:id/revoke`, naming the person in `X-OV-Subject`. Only the network's own service (`svc:network`) may: any other service with the capability is refused (`403 bot.forbidden`) |
| `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 |
@@@ -226,7 +228,7 @@ Set `BOT_ONVIF_CAMERAS` (JSON, unset by default) to the server-side cameras Bot
OpenVibe.Sites no longer generates `deploy/nginx/openvibe.bot.conf` (sites.json marks the vhost as owned by OpenVibe.Bot), and Bot serves nothing from a Sites checkout: the legal pages, `robots.txt`, `sitemap.xml`, `manifest.webmanifest` and the 404 page all come from the app. A Sites deploy leaves an installed vhost it no longer generates in place, so the current Sites vhost keeps serving openvibe.bot until Bot's first deploy installs this file over it; later Sites deploys never overwrite Bot's `/device` and `/control` routes.
<!-- versions:start -->
--- openvibe-contracts: v0.223.0
++- openvibe-contracts: v0.261.0
- openvibe-sdk: v0.74.0
- openvibe-shared: v4.66.0
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex 9b5bd4c..49e35a1 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -5,11 +5,11 @@
"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.223.0",
++ "contracts": "openvibe-contracts v0.261.0",
"sdk": "openvibe-sdk v0.74.0",
"shared": "openvibe-shared v4.66.0",
"packages": [
-- "openvibe-contracts v0.223.0",
++ "openvibe-contracts v0.261.0",
"openvibe-sdk v0.74.0",
"openvibe-shared v4.66.0"
],
@@@ -19,6 +19,7 @@
"bot.robot.control",
"bot.computer.control",
"bot.machine.ai",
++ "bot.machine.manage",
"bot.desk.session",
"bot.device.connect",
"bot.job.dispatch",
@@@ -61,7 +62,8 @@
"embeddable panel: GET /panel/:id/embed framed only by BOT_EMBED_ORIGINS (no cookie, never a sign-in redirect; a member keeps their role, anyone else watches an embed_public robot) and the anonymous read-only /watch socket carrying the public state (online, e-stop, latency, battery, the profile's readouts), capped per address and per robot",
"streaming and recording toggles per robot, off by default and owner-only: media records live sessions to OpenVibe.Media (the OpenRestream stream's recording_mode vod/none) and live shows them on the owner's OpenVibe.Live channel (mirror_to_live), read at GET /api/v1/robots/:id/streaming and set at POST /api/v1/robots/:id/streaming; Bot stores no copy, each real change audits once",
"server-side ONVIF cameras (profile camera.onvif, driver onvif, server/onvif): an in-process device beside the simulator attaches a camera configured with BOT_ONVIF_CAMERAS (its URL plus username_ref/password_ref secret references, never in the profile or in clear), writes a real kind server device row, translates ptz commands to ONVIF ContinuousMove/Stop with the profile deadline as a deadman, and carries the camera's GetStatus readout in telemetry; every request goes only to the configured host, with a timeout and no redirects",
-- "a person's own machines that run their jobs (/machines: add with a --worker command, list, remove); their worker offers reported to Network as user-owned offers naming the owner; jobs reach a machine only for its owner (plan T14/T16)"
++ "a person's own machines that run their jobs (/machines: add with a --worker command, list, remove); their worker offers reported to Network as user-owned offers naming the owner; jobs reach a machine only for its owner (plan T14/T16)",
++ "machines hub (plan L34): Bot stays the registry; the hub's routes (GET /api/v1/machines, GET /api/v1/machines/:id, POST /api/v1/machines/pairings, PATCH /api/v1/machines/:id, PUT /api/v1/machines/:id/roles, POST /api/v1/machines/:id/revoke) serve the person and the network's own service (svc:network, and only that one) acting for them (bot.machine.manage, X-OV-Subject), with the machine's roles (migration 0028) in the device's config frame and its online state reported to Network once per change"
],
"updated": "2026-10-31"
}
ddiff --git a/migrations/0028_machine_roles.sql b/migrations/0028_machine_roles.sql
nnew file mode 100644
iindex 0000000..e82e9b5
---- /dev/null
++++ b/migrations/0028_machine_roles.sql
@@@ -0,0 +1,6 @@
++-- phase: expand
++-- Plan L34 (the machines hub): the roles a machine has for the network (the catalogue is Contracts 0.257.0's
++-- machineRoles: codes.worker, desks.host, ai.host, bot.robot). The owner sets them on openvibe.network/machines and
++-- the Node reads them on its next check-in (the config frame carries them). A machine that never had a role has the
++-- empty list, which is what the column starts with.
++ALTER TABLE devices ADD COLUMN roles jsonb NOT NULL DEFAULT '[]'::jsonb;
ddiff --git a/package-lock.json b/package-lock.json
iindex 47edf6d..b908d7e 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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.223.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.261.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.66.0",
"pg": "^8.23.0",
@@@ -1120,9 +1120,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.223.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.223.0",
-- "integrity": "sha512-FWcPL1951IeYcelp3dUiDRJEiFkTbsLMLth9RVUFfuLTuHGQKoQXA5HIu6H2e2BupPTyBH+AZD00LqmA6jDmdg==",
++ "version": "0.261.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.261.0",
++ "integrity": "sha512-SiXOcXRL9+WivMkKM1W6sGNJMD4uCBR9o7KYZvs99hT2xgYxDvP4rM7tLBudGu1boPH+coMdUXVQAAWbS/3JDw==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex 32c15ba..5b64197 100644
---- a/package.json
++++ b/package.json
@@@ -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.223.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.261.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.66.0",
"pg": "^8.23.0",
ddiff --git a/server/api/v1.js b/server/api/v1.js
iindex 3e6fa31..2d9c249 100644
---- a/server/api/v1.js
++++ b/server/api/v1.js
@@@ -44,6 +44,7 @@ const { http } = require('openvibe-contracts');
const { fail, userSubject, isRobotId, json } = require('../util');
const { createMachineScreen } = require('../machine-screen');
const { getProfile, listProfiles, checkProfile } = require('../profiles');
++const machineRoles = require('../machine-roles');
const kits = require('../kits');
const resourceIndex = require('./resource-index');
const resourceControl = require('./resource-control');
@@@ -57,6 +58,8 @@ const CAP = {
dispatch: 'bot.job.dispatch',
computer: 'bot.computer.control',
ai: 'bot.machine.ai',
++ // The network's machines hub (plan L34): a service acts for a person on their own machines.
++ machine: 'bot.machine.manage',
};
/** Services that act as a person's agent (plan T15/T17): they drive a robot only where its owner turned agent_control on. */
@@@ -71,6 +74,10 @@ const MACHINE_SERVICES = {
// AI on your own machines (contracts 0.219.0): OpenVibe.AI runs a model on the owner's own model server, nothing else.
'svc:ai': { kinds: new Set(['ai']), flag: 'ai_models', off: 'the owner has not let their AI use this computer\'s models', cap: 'bot.machine.ai' },
};
++// The network's machines hub (plan L34): the one service that acts for a person on their own machines. The
++// capability alone is not enough: any service holding it could name any person in X-OV-Subject, so the hub is
++// this service and no other.
++const HUB_SERVICE = 'svc:network';
// A model on a person's machine is OpenVibe.AI's and the owner's only: no other service sends kind ai.
const AI_KIND = 'ai';
@@@ -93,6 +100,8 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
const commandControl = limits.actedFor('bot.control', { minute: 120, hour: 1200 },
{ minute: config.actorLimits.controlServiceMinute, hour: config.actorLimits.controlServiceHour });
const pair = limits('bot.pair', { minute: 20, hour: 200 });
++ // The hub's routes: a service naming a person is counted against that person, as their own token is.
++ const machineManage = limits.actedFor('bot.machine.manage', { minute: 30, hour: 300 });
const jobs = limits('bot.jobs', { minute: 120, hour: 1200 });
const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
@@@ -435,6 +444,14 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
return {
id: device.id, name: device.name || null, online: !!st.online, agent_computer: !!device.agent_computer, ai_models: !!device.ai_models, watch_probe: !!device.watch_probe,
shared_desks: !!device.shared_desks,
++ // What the machine does for the network (plan L34): the roles the owner gave it. The catalogue is
++ // server/machine-roles.js; the Node reads them on its next check-in.
++ roles: json(device.roles, []),
++ // The machine's kind, its operating system and the OpenVibe.Node's version, as the Node last said.
++ kind: device.kind || null,
++ os: (st.status && typeof st.status.os === 'string' && st.status.os) || null,
++ node_version: device.agent_version || null,
++ last_seen_at: device.last_seen ? new Date(device.last_seen).toISOString() : null,
// The machine's id on Network (`nod_…`) and the OpenVibe.Node's name for it, so a caller that knows the
// machine from Network (Codes' fleet, a Run pin) finds this row (Codes C9c).
node_id: device.node_principal || null,
@@@ -455,20 +472,47 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
} : null,
};
};
++ /**
++ * What the machine is doing right now (plan L34: the hub shows it): its Codes jobs, its desks, the local models
++ * it serves and the robots it drives. Only what is true now: a machine that is offline runs nothing.
++ */
++ const machineRunning = async (device) => {
++ const st = hub.deviceState(device.id);
++ const offered = hub.computerOf(device.id);
++ const sensors = st.telemetry && st.telemetry.sensors && typeof st.telemetry.sensors === 'object' ? st.telemetry.sensors : {};
++ const ai = offered && offered.ai && typeof offered.ai === 'object' ? offered.ai : null;
++ const out = [];
++ if (st.online) {
++ const jobs = await domain.machines.runningJobs(device.id);
++ if (jobs > 0) out.push({ kind: 'codes.jobs', count: jobs });
++ const desks = Number.isInteger(sensors.computer_desks) ? sensors.computer_desks : await domain.deskLeases.count(device.id);
++ if (desks > 0) out.push({ kind: 'desks', count: desks });
++ const models = ai && Array.isArray(ai.models) ? ai.models.length : 0;
++ if (models > 0) out.push({ kind: 'models', count: models, label: ai.model || undefined });
++ const robots = json(device.robot_ids, []).length;
++ if (robots > 0) out.push({ kind: 'robots', count: robots });
++ }
++ return out;
++ };
++ /** The machine as the hub sees it: everything machineOut says, and what it does right now. */
++ const machineView = async (device) => ({ ...machineOut(device), running: await machineRunning(device) });
// The person's machines (theirs or their robots'); an agent sees only those whose owner let it use them.
r.get('/machines', wrap(async (req, res) => {
const subject = actedFor(req, req.query.owner);
const svc = req.principal.kind === 'service' ? MACHINE_SERVICES[req.principal.sub] : null;
++ // The network's hub (plan L34): it fronts Bot for the person and names them in X-OV-Subject. It is the
++ // network's own service: no other one may list a person's machines as them.
++ const hubService = req.principal.kind === 'service' && req.principal.sub === HUB_SERVICE && apiAuth.granted(req.principal, CAP.machine);
if (req.principal.kind === 'service') {
-- if (!svc) fail(403, 'bot.forbidden', 'machines are listed for their owner, their agent, their watches and their AI');
++ if (!svc && !hubService) fail(403, 'bot.forbidden', 'machines are listed for their owner, their agent, their watches, their AI and the network\'s hub');
// Each service with its own capability: OpenVibe.AI lists those whose models the owner's AI may use
// (a node credential with machine `any`, contracts 0.221.0).
-- const cap = svc.cap || CAP.computer;
++ const cap = svc ? (svc.cap || CAP.computer) : CAP.machine;
if (!apiAuth.granted(req.principal, cap)) fail(403, 'capability.denied', `${cap} not granted`);
}
const rows = await domain.machines.list(subject);
const mine = svc ? rows.filter((d) => d[svc.flag]) : rows;
-- res.json({ machines: mine.map(machineOut) });
++ res.json({ machines: await Promise.all(mine.map((d) => machineView(d))) });
}));
r.get('/machines/:id/computer', wrap(async (req, res) => {
const { device } = await computerMachine(req);
@@@ -552,6 +596,80 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
res.json({ machine: machineOut(updated) });
}));
++ // ── The machines hub (plan L34, step M2) ───────────────────────────────────────────────────────
++ // The machine's owner, or the network's hub (OpenVibe.Network) acting for them: a service holding
++ // bot.machine.manage and naming the person in X-OV-Subject. Bot stays the registry underneath, so the
++ // machine is the named person's own or it is not there at all (404, never 403: nobody may probe whose
++ // machine an id is). A person acts for themself and for nobody else.
++ const hubSubject = (req) => {
++ if (req.principal.kind !== 'service') fail(403, 'bot.forbidden', 'only the machine\'s owner or the network\'s hub manages a machine');
++ // The hub is the network's own service, and only that one: the capability alone would let any service
++ // that holds it name any person in X-OV-Subject and act as them.
++ if (req.principal.sub !== HUB_SERVICE) fail(403, 'bot.forbidden', 'only the network\'s hub manages a machine for a person');
++ if (!apiAuth.granted(req.principal, CAP.machine)) fail(403, 'capability.denied', `${CAP.machine} not granted`);
++ const s = req.headers['x-ov-subject'];
++ if (!s) fail(422, 'bot.invalid_input', 'the hub acts for a person (X-OV-Subject)');
++ return userSubject(s, 'subject');
++ };
++ /** The machine, and whose it is. → { device, subject } */
++ const hubMachine = async (req) => {
++ if (req.principal.kind === 'anonymous') fail(401, 'bot.sign_in', 'sign in to do that');
++ const subject = req.principal.kind === 'user'
++ ? self(req, req.headers['x-ov-subject'], req.body?.owner)
++ : hubSubject(req);
++ const device = await domain.devices.get(String(req.params.id || '').slice(0, 64));
++ const owner = device && !device.revoked_at ? await domain.devices.ownerOf(device) : null;
++ if (!owner || owner !== subject) fail(404, 'bot.device_not_found', 'no such machine of yours');
++ return { device, subject };
++ };
++ r.get('/machines/:id', wrap(async (req, res) => {
++ const { device } = await hubMachine(req);
++ res.json({ machine: await machineView(device) });
++ }));
++ // A new name for the machine.
++ r.patch('/machines/:id', machineManage, wrap(async (req, res) => {
++ const { device } = await hubMachine(req);
++ const name = typeof req.body?.name === 'string' ? req.body.name.trim().slice(0, 80) : '';
++ if (!name) fail(422, 'bot.invalid_input', 'a name is 1 to 80 characters');
++ const updated = await domain.machines.rename(device.id, name);
++ res.json({ machine: await machineView(updated) });
++ }));
++ // The machine's roles for the network: the whole set, not a change. A role outside the catalogue is refused.
++ r.put('/machines/:id/roles', machineManage, wrap(async (req, res) => {
++ const { device } = await hubMachine(req);
++ let roles;
++ try {
++ roles = machineRoles.normalise(req.body?.roles);
++ } catch (e) {
++ if (e && e.unknown) fail(422, 'bot.unknown_role', `${e.role} is not a machine role`);
++ fail(422, 'bot.invalid_input', 'roles must be a list of machine roles');
++ }
++ const updated = await domain.machines.setRoles(device.id, roles);
++ if (hub && hub.machineChanged) await hub.machineChanged(updated);
++ res.json({ machine: await machineView(updated) });
++ }));
++ // A one-time code to pair a machine with the person's account, and the command to run on it. The code is
++ // shown once here and never again; a new code replaces an unused one.
++ r.post('/machines/pairings', pair, wrap(async (req, res) => {
++ if (req.principal.kind === 'anonymous') fail(401, 'bot.sign_in', 'sign in to do that');
++ const subject = req.principal.kind === 'user'
++ ? self(req, req.headers['x-ov-subject'], req.body?.owner)
++ : hubSubject(req);
++ const pairing = await domain.machines.mint(subject);
++ res.status(201).json({ pairing: { code: pairing.code, expires_at: pairing.expires_at, pairing_id: pairing.pairing_id, installer: pairing.installer } });
++ }));
++ // The machine is no longer the person's: its credential dies here, and its node principal on Network dies with it.
++ r.post('/machines/:id/revoke', machineManage, wrap(async (req, res) => {
++ const { device } = await hubMachine(req);
++ const revoked = await domain.devices.revoke(device.id);
++ let networkError = null;
++ if (device.node_principal) await domain.devices.revokeNode(device.node_principal).catch((e) => { networkError = e; });
++ await domain.devices.revokeVideo(device.id).catch((e) => { networkError = networkError || e; });
++ hub.closeDevice(device.id, 'revoked');
++ if (networkError) throw networkError;
++ res.json({ machine: await machineView(revoked) });
++ }));
++
// ── Steps (the owner's agent): plain step lines, run now as the owner and answered when they end ──
// The owner themself, or an agent service (OpenVibe.Actor) acting for the owner of a robot with agent_control on.
// Every step passes the same gate as a person's command (server/routines.js runSteps).
ddiff --git a/server/domain/index.js b/server/domain/index.js
iindex 72860fe..677083e 100644
---- a/server/domain/index.js
++++ b/server/domain/index.js
@@@ -18,6 +18,7 @@ const {
BotError, fail, prefixedId, iso, token, hashSecret, secretEquals, json, text, storable, isRobotId,
} = require('../util');
const { getProfile, validateProfile, reservedKeyReason } = require('../profiles');
++const machineRoles = require('../machine-roles');
const { ENVELOPE } = require('../events/outbox');
/** A person's subject (the owner a worker machine's pairing names). */
const USER_SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
@@@ -76,6 +77,8 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
const changeListeners = new Set();
const onChange = (fn) => { changeListeners.add(fn); return () => changeListeners.delete(fn); };
const changed = () => { for (const fn of changeListeners) { try { fn(); } catch { /* a listener never breaks the caller */ } } };
++ /** The last online state Bot reported to Network for a machine, so a change is reported once. */
++ const reportedState = new Map();
const workerRef = (subject) => `worker:${subject}`;
// ── Presenters ────────────────────────────────────────────────────────────────────────────────
const presentRobot = (r) => (r ? {
@@@ -798,6 +801,49 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
async function listSharedDesks() {
return db.many('SELECT * FROM devices WHERE revoked_at IS NULL AND shared_desks = true ORDER BY created_at');
}
++ /**
++ * The roles a machine has for the network (plan L34, step M4): the owner's choice, checked against the
++ * catalogue (server/machine-roles.js) and stored whole. The Node reads them on its next check-in.
++ * The owner's check is the caller's.
++ */
++ async function setMachineRoles(deviceId, roles) {
++ const wanted = machineRoles.normalise(roles);
++ return db.maybe('UPDATE devices SET roles = $2::jsonb, updated_at = $3 WHERE id = $1 AND revoked_at IS NULL RETURNING *',
++ [deviceId, JSON.stringify(wanted), iso(now())]);
++ }
++ /** The name the owner gave a machine (plan L34). The owner's check is the caller's. */
++ async function renameMachine(deviceId, name) {
++ return db.maybe('UPDATE devices SET name = $2, updated_at = $3 WHERE id = $1 AND revoked_at IS NULL RETURNING *',
++ [deviceId, name, iso(now())]);
++ }
++ /**
++ * The machine's online state, as Network publishes it (plan L34: the hub's network.machine.online and
++ * network.machine.offline events). Bot is the registry, so Bot is the one that knows; Network turns the
++ * change into an event. A Network that does not answer loses the event, never the machine's state.
++ */
++ async function reportMachineStatus(device, online) {
++ if (!nodes || !device) return null;
++ // One report per change: a machine that reconnects is one more offline and one more online, never two of
++ // the same (Network publishes an event for each report).
++ if (reportedState.get(device.id) === !!online) return null;
++ reportedState.set(device.id, !!online);
++ const owner = device.owner_subject || await ownerOf(device);
++ if (!owner) return null;
++ try {
++ return await nodes.status({
++ machine_id: device.id, principal_id: device.node_principal || null,
++ owner, online: !!online, at: iso(now()),
++ });
++ } catch (e) {
++ log.warn(`[Bot] the machine's state for Network: ${e.message}`);
++ return null;
++ }
++ }
++ /** How many jobs the machine runs right now (plan L34: what the hub shows). */
++ async function runningJobs(deviceId) {
++ const row = await db.one('SELECT count(*)::int AS n FROM run_jobs WHERE node_id = $1 AND state = $2', [deviceId, 'running']);
++ return Number(row && row.n ? row.n : 0);
++ }
// ── Desk leases (contracts 0.223.0 bot.desk-lease@1, the Desks plan's D6) ──────────────────────────────────────
const leaseOut = (r) => (r ? {
@@@ -1329,6 +1375,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
pairing: { create: createPairingCode, redeem, prune: prunePairingCodes, installerCommand, driverForProfile, whipUrl, onPaired },
devices: { byCredential, bindNode, issuePublishKey, revokeVideo, updateDeclared, setFleet, revokeNode, ensureServerDevice, get: getDevice, listForRobot: listDevicesForRobot, rotate: rotateDevice, revoke: revokeDevice, revokeByNode, touchSeen, setOnline, ownerOf, onChange },
machines: { mint: mintMachine, list: listMachines, workerRef, setAgentComputer, setAiModels, setWatchProbe, setVoice, setSharedDesks, listSharedDesks,
++ setRoles: setMachineRoles, rename: renameMachine, runningJobs, reportStatus: reportMachineStatus,
screen: { key: screenKey, liveKey: liveScreenKey, live: screenLive, end: screenEnd, archive: screenArchive } },
deskLeases,
streaming: { get: streaming, set: setStreaming },
ddiff --git a/server/machine-roles.js b/server/machine-roles.js
nnew file mode 100644
iindex 0000000..7a3dfb8
---- /dev/null
++++ b/server/machine-roles.js
@@@ -0,0 +1,54 @@
++'use strict';
++/**
++ * The catalogue of machine roles (plan L34, step M4: a product declares a machine role — a name, what it installs
++ * and what the hub shows — and the Node turns it on at its next check-in).
++ *
++ * The catalogue lives in the Contracts release: each product's service manifest carries its own roles
++ * (`machineRoles`, openvibe-contracts 0.261.0), and the hub's catalogue is the union of them. This service pins
++ * that release, so the manifests are the catalogue; the copy below is the fallback for an older pin, and goes
++ * with it. Bot only ever refuses a role outside the catalogue: a role is a promise the Node has to keep, so an
++ * unknown one is refused rather than stored.
++ *
++ * catalogue() the roles, as [{ id, name, installs, shows }]
++ * isKnown(id) whether the catalogue has that role
++ * normalise(list) the roles of a request, checked: the same ids, no duplicates, no unknown role
++ */
++const contracts = require('openvibe-contracts');
++
++/** The fallback catalogue, for a pin that does not carry the manifests' roles. Keep it in step with them. */
++const LOCAL = [
++ { id: 'codes.worker', name: 'Codes worker', installs: 'the Codes job runner (the coding agent and the job sandbox)', shows: 'Runs your coding jobs, such as a task you start on openvibe.codes.' },
++ { id: 'desks.host', name: 'Desk host', installs: 'the desk sandbox (a private browser desk for every session)', shows: "Hosts desks for agents, yours and other people's." },
++ { id: 'ai.host', name: 'AI host', installs: "the local model server (Ollama, LM Studio) as OpenVibe Node's ai plugin", shows: 'Serves your own AI models, on your own machine.' },
++ { id: 'bot.robot', name: 'Robot', installs: 'the robot drivers the profile asks for', shows: 'Drives a robot, and takes its commands.' },
++];
++
++/** The roles the products declare (Contracts 0.261.0), or the local copy when the pin does not carry them. */
++function catalogue() {
++ const declared = (contracts.services.manifests || []).flatMap((m) => Array.isArray(m.machineRoles) ? m.machineRoles : []);
++ return declared.length ? declared : LOCAL;
++}
++
++const known = () => new Set(catalogue().map((r) => r.id));
++
++/** Whether the catalogue has that role. */
++function isKnown(id) { return known().has(String(id || '')); }
++
++/**
++ * The roles of a request, checked: a list of ids, each in the catalogue, no duplicates. Returns the ids in the
++ * catalogue's order (so the same set always reads the same way). Throws with `unknown` when a role is not in the
++ * catalogue; the caller answers 422.
++ */
++function normalise(list) {
++ if (!Array.isArray(list)) { const e = new Error('roles must be a list'); e.unknown = false; throw e; }
++ const want = known();
++ const out = [];
++ for (const raw of list) {
++ const id = String(raw || '');
++ if (!want.has(id)) { const e = new Error(`unknown role ${id}`); e.unknown = true; e.role = id; throw e; }
++ if (!out.includes(id)) out.push(id);
++ }
++ return catalogue().map((r) => r.id).filter((id) => out.includes(id));
++}
++
++module.exports = { LOCAL, catalogue, isKnown, normalise };
ddiff --git a/server/network.js b/server/network.js
iindex 184d273..434443f 100644
---- a/server/network.js
++++ b/server/network.js
@@@ -8,8 +8,10 @@
* unknown kid). They verify service and node tokens (audience openvibe.bot) and the browser's Network
* user JWT, all offline (openvibe-sdk/auth).
* nodes Network's node principals (machines paired for Bot): POST /internal/node-pairings (a pairing
-- * code, BOT_PAIRING_AUTHORITY=network), GET /internal/node-principals/:id and POST …/:id/revoke with
-- * Bot's own svc:bot token (audience openvibe.network, network.node.manage).
++ * code, BOT_PAIRING_AUTHORITY=network), GET /internal/node-principals/:id, POST …/:id/revoke and
++ * POST /internal/machines/status (the machine's online state, which Network publishes as the
++ * hub's network.machine.online and network.machine.offline events) with Bot's own svc:bot token
++ * (audience openvibe.network, network.node.manage).
* identity Network's people (identity.subject.resolve): resolve a username to a subject and look up the
* names of subjects the robot's owner added.
*/
@@@ -84,6 +86,8 @@ function createNodePrincipals(config, { fetchImpl = globalThis.fetch } = {}) {
get: (id) => call('GET', principal(id)),
revoke: (id) => call('POST', `${principal(id)}/revoke`),
pair: ({ subject, ref }) => call('POST', '/internal/node-pairings', { owner: { kind: 'user', subject }, ref }),
++ // The machine's online state (plan L34): Network publishes it as the hub's machine events.
++ status: (body) => call('POST', '/internal/machines/status', body),
};
}
ddiff --git a/server/realtime.js b/server/realtime.js
iindex c7b87e3..33ca68e 100644
---- a/server/realtime.js
++++ b/server/realtime.js
@@@ -342,6 +342,9 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
estop_latched: robot ? !!robot.estop_latched : false,
// Vibe by voice: the machine listens for its call word only while its owner has voice on.
...(conn.device.voice_on ? { voice: true, call_word: voice.callWordOf(conn.device) } : {}),
++ // What the machine does for the network (plan L34): the roles the owner gave it on the hub. The Node
++ // turns them on at its next check-in (and at once when they change while it is connected).
++ roles: json(conn.device.roles, []),
});
}
/** Re-send `config` to the robot's connected devices (the owner changed its limits or allowlist, or the latch moved). */
@@@ -358,7 +361,9 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
/** The online/offline event and last_seen are a real machine's: a simulator (no devices row) reports neither. */
function reportOnline(conn, online) {
if (conn.sim) return Promise.resolve();
-- return domain.devices.setOnline(conn.device, online).catch((e) => log.warn(`[Bot] ${online ? 'online' : 'offline'} event: ${e.message}`));
++ return domain.devices.setOnline(conn.device, online)
++ .then(() => domain.machines.reportStatus(conn.device, online))
++ .catch((e) => log.warn(`[Bot] ${online ? 'online' : 'offline'} event: ${e.message}`));
}
async function bringOnline(conn) {
await reportOnline(conn, true);
ddiff --git a/server/web/render.js b/server/web/render.js
iindex b573718..c3bf498 100644
---- a/server/web/render.js
++++ b/server/web/render.js
@@@ -842,6 +842,7 @@ ${choice('Use the models you run on it', ' --ai', 'If this computer runs Ollama,
<main class="page">
<h1>Your machines</h1>
<p class="muted">A machine here runs jobs for you and only you. It connects out to OpenVibe and needs no open ports. The limits in its own config and its local kill switch (<code>openvibe-node stop</code>) always win.</p>
++<p class="muted"><a href="https://openvibe.network/machines">All your machines are on openvibe.network/machines</a></p>
${list}
${add}
</main>`;
ddiff --git a/test/helpers/stubs.js b/test/helpers/stubs.js
iindex dbcd8c6..eaf3e9f 100644
---- a/test/helpers/stubs.js
++++ b/test/helpers/stubs.js
@@@ -6,7 +6,8 @@
* client-credentials tokens and records every request in tokenCalls ({client_id, audience,
* scope}); node principals as
* GET /internal/node-principals/:id and POST …/:id/revoke answer them (addNode, signNode,
-- * nodes, revokes), scoped to the calling service like Network's own routes. Identity
++ * nodes, revokes), scoped to the calling service like Network's own routes; the machine's
++ * online state (POST /internal/machines/status) is recorded (statuses). Identity
* resolution for people (identity.subject.resolve): GET /internal/identity/resolve?username=
* and POST /internal/identity/resolve-batch, over the users newUser registers; failIdentity
* makes them answer a failure (Network down).
@@@ -88,6 +89,9 @@ async function startNetwork() {
let pairingFailure = null;
const failPairings = (status) => { pairingFailure = status; };
++ // The machine's online state (POST /internal/machines/status): every report Bot sends is kept.
++ const statuses = [];
++
// Identity resolution for people (identity.subject.resolve). `users` is what newUser registered;
// failIdentity(status) makes both routes answer that status instead (Network down).
const users = new Map(); // subject → { subject, username, display_name }
@@@ -146,6 +150,15 @@ async function startNetwork() {
pairings.push({ service, body, ...out });
return send(res, 201, out);
}
++ // The machine's online state (POST /internal/machines/status, plan L34): every report is kept, so a test
++ // sees that a change is reported once and a repeat is not.
++ if (req.url === '/internal/machines/status' && req.method === 'POST') {
++ const service = nodeManager(req);
++ if (!service) return send(res, 401, { code: 'token.invalid' });
++ const body = JSON.parse((await readBody(req)) || '{}');
++ statuses.push({ service, body });
++ return send(res, 200, { ok: true });
++ }
const np = /^\/internal\/node-principals\/([^/]+)(\/revoke)?$/.exec(req.url);
if (np && (req.method === (np[2] ? 'POST' : 'GET'))) {
const service = nodeManager(req);
@@@ -162,7 +175,7 @@ async function startNetwork() {
});
const url = await listen(server);
issuer = url;
-- return { url, publicPem, signService, signUser, newUser, addNode, signNode, nodes, revokes, pairings, tokenCalls, failPairings, users, failIdentity: (status) => { identityFailure = status; }, close: () => new Promise((r) => server.close(r)) };
++ return { url, publicPem, signService, signUser, newUser, addNode, signNode, nodes, revokes, pairings, statuses, tokenCalls, failPairings, users, failIdentity: (status) => { identityFailure = status; }, close: () => new Promise((r) => server.close(r)) };
}
async function startEvents() {
ddiff --git a/test/machines-hub.test.js b/test/machines-hub.test.js
nnew file mode 100644
iindex 0000000..d9f40f4
---- /dev/null
++++ b/test/machines-hub.test.js
@@@ -0,0 +1,165 @@
++'use strict';
++// The machines hub (plan L34, step M2): Bot stays the registry of the machines. A person sees their own
++// machines and nobody else's; the network's hub (a service holding bot.machine.manage) acts for a person it
++// names in X-OV-Subject; a role outside the catalogue is refused; a pairing code is one code for the person;
++// the roles reach the Node in its config frame; and a machine's online state is reported to Network once per
++// change.
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/app');
++
++(async () => {
++ const t = await boot();
++ const alex = t.network.newUser('alex');
++ const sam = t.network.newUser('sam');
++
++ /** A worker machine of `owner`, paired on Network and bound here, so Bot has a device row for it. */
++ async function machine(owner, { name = 'garage-pi' } = {}) {
++ const principal = t.network.addNode({ owner: owner.subject, ref: `worker:${owner.subject}`, name });
++ const r = await t.call('POST', '/api/v1/devices/bind', { token: t.network.signNode(principal) });
++ assert.strictEqual(r.status, 201, r.text);
++ return { id: r.json.device_id, principal };
++ }
++ /** The hub's own call: the service that fronts Bot for a person. */
++ const hub = (path, { body, subject = alex.subject, cap = ['bot.machine.manage'] } = {}) =>
++ t.call(path.method, path.url, { body, sub: 'svc:network', cap, headers: { 'X-OV-Subject': subject } });
++
++ const alexMachine = await machine(alex);
++ const samMachine = await machine(sam, { name: 'sam-pi' });
++
++ await check('a person sees their own machines and nobody else\'s', async () => {
++ const mine = await t.call('GET', '/api/v1/machines', { user: alex });
++ assert.strictEqual(mine.status, 200, mine.text);
++ assert.deepStrictEqual(mine.json.machines.map((m) => m.id), [alexMachine.id]);
++ assert.strictEqual(mine.json.machines[0].name, 'garage-pi');
++ assert.strictEqual(mine.json.machines[0].roles.length, 0);
++ assert.deepStrictEqual(mine.json.machines[0].running, []);
++ const theirs = await t.call('GET', '/api/v1/machines', { user: sam });
++ assert.deepStrictEqual(theirs.json.machines.map((m) => m.id), [samMachine.id]);
++ // One machine of another person is not there at all, never a 403 that says it exists.
++ const one = await t.call('GET', `/api/v1/machines/${samMachine.id}`, { user: alex });
++ assert.strictEqual(one.status, 404, one.text);
++ assert.strictEqual(one.json.code, 'bot.device_not_found');
++ });
++
++ await check('the hub acts for the person: rename, roles and revoke reach Bot as them', async () => {
++ const renamed = await hub({ method: 'PATCH', url: `/api/v1/machines/${alexMachine.id}` }, { body: { name: 'Studio' } });
++ assert.strictEqual(renamed.status, 200, renamed.text);
++ assert.strictEqual(renamed.json.machine.name, 'Studio');
++ const roles = await hub({ method: 'PUT', url: `/api/v1/machines/${alexMachine.id}/roles` }, { body: { roles: ['codes.worker'] } });
++ assert.strictEqual(roles.status, 200, roles.text);
++ assert.deepStrictEqual(roles.json.machine.roles, ['codes.worker']);
++ const row = await t.db.maybe('SELECT name, roles FROM devices WHERE id = $1', [alexMachine.id]);
++ assert.strictEqual(row.name, 'Studio');
++ assert.deepStrictEqual(row.roles, ['codes.worker']);
++ });
++
++ await check('the hub needs the capability and the person, and never another person\'s machine', async () => {
++ const noCap = await t.call('PATCH', `/api/v1/machines/${alexMachine.id}`, { body: { name: 'Nope' }, sub: 'svc:network', cap: ['bot.read'], headers: { 'X-OV-Subject': alex.subject } });
++ assert.strictEqual(noCap.status, 403, noCap.text);
++ assert.strictEqual(noCap.json.code, 'capability.denied');
++ const noSubject = await t.call('PATCH', `/api/v1/machines/${alexMachine.id}`, { body: { name: 'Nope' }, sub: 'svc:network', cap: ['bot.machine.manage'] });
++ assert.strictEqual(noSubject.status, 422, noSubject.text);
++ const other = await hub({ method: 'PATCH', url: `/api/v1/machines/${samMachine.id}` }, { body: { name: 'Stolen' } });
++ assert.strictEqual(other.status, 404, other.text);
++ assert.strictEqual(other.json.code, 'bot.device_not_found');
++ const same = await t.call('PATCH', `/api/v1/machines/${samMachine.id}`, { body: { name: 'Stolen' }, user: alex });
++ assert.strictEqual(same.status, 404, same.text);
++ });
++
++ await check('the hub is the network\'s own service: another service with the capability is refused', async () => {
++ // The capability alone must not be enough: any service holding it could name any person in X-OV-Subject
++ // and act as them, so only the network's own service may.
++ const other = (path, body) => t.call(path.method, path.url, { body, sub: 'svc:codes', cap: ['bot.machine.manage'], headers: { 'X-OV-Subject': alex.subject } });
++ const list = await other({ method: 'GET', url: '/api/v1/machines' });
++ assert.strictEqual(list.status, 403, list.text);
++ assert.strictEqual(list.json.code, 'bot.forbidden');
++ const renamed = await other({ method: 'PATCH', url: `/api/v1/machines/${alexMachine.id}` }, { name: 'Stolen' });
++ assert.strictEqual(renamed.status, 403, renamed.text);
++ assert.strictEqual(renamed.json.code, 'bot.forbidden');
++ const roles = await other({ method: 'PUT', url: `/api/v1/machines/${alexMachine.id}/roles` }, { roles: ['codes.worker'] });
++ assert.strictEqual(roles.status, 403, roles.text);
++ assert.strictEqual(roles.json.code, 'bot.forbidden');
++ const pairing = await other({ method: 'POST', url: '/api/v1/machines/pairings' }, {});
++ assert.strictEqual(pairing.status, 403, pairing.text);
++ assert.strictEqual(pairing.json.code, 'bot.forbidden');
++ // Nothing of the person's changed.
++ const row = await t.db.maybe('SELECT name, roles FROM devices WHERE id = $1', [alexMachine.id]);
++ assert.strictEqual(row.name, 'Studio');
++ assert.deepStrictEqual(row.roles, ['codes.worker']);
++ });
++
++ await check('a role outside the catalogue is refused and nothing is stored', async () => {
++ const before = await t.db.maybe('SELECT roles FROM devices WHERE id = $1', [alexMachine.id]);
++ const bad = await hub({ method: 'PUT', url: `/api/v1/machines/${alexMachine.id}/roles` }, { body: { roles: ['codes.worker', 'nope.nope'] } });
++ assert.strictEqual(bad.status, 422, bad.text);
++ assert.strictEqual(bad.json.code, 'bot.unknown_role');
++ const after = await t.db.maybe('SELECT roles FROM devices WHERE id = $1', [alexMachine.id]);
++ assert.deepStrictEqual(after.roles, before.roles, 'the roles are left as they were');
++ // A role in the catalogue but of another shape is refused too.
++ const shape = await hub({ method: 'PUT', url: `/api/v1/machines/${alexMachine.id}/roles` }, { body: { roles: 'codes.worker' } });
++ assert.strictEqual(shape.status, 422, shape.text);
++ });
++
++ await check('a pairing is one code for the person, with the command to run', async () => {
++ const r = await hub({ method: 'POST', url: '/api/v1/machines/pairings' }, { body: {} });
++ assert.strictEqual(r.status, 201, r.text);
++ const p = r.json.pairing;
++ assert.match(p.code, /^[0-9A-HJKMNP-TV-Z]{4}-[0-9A-HJKMNP-TV-Z]{4}$/, p.code);
++ assert.ok(p.expires_at && new Date(p.expires_at) > new Date(), 'the code has an expiry');
++ assert.match(p.installer, /--pairing/, p.installer);
++ // Network is the authority for the code: it minted it for this person and this worker ref.
++ const minted = t.network.pairings[t.network.pairings.length - 1];
++ assert.strictEqual(minted.body.owner.subject, alex.subject);
++ assert.strictEqual(minted.body.ref, `worker:${alex.subject}`);
++ // Another person gets another code, for them.
++ const other = await hub({ method: 'POST', url: '/api/v1/machines/pairings' }, { body: {}, subject: sam.subject });
++ assert.strictEqual(other.status, 201, other.text);
++ assert.notStrictEqual(other.json.pairing.code, p.code);
++ // A person may pair for themself without the hub.
++ const own = await t.call('POST', '/api/v1/machines/pairings', { user: alex, body: {} });
++ assert.strictEqual(own.status, 201, own.text);
++ });
++
++ await check('the roles reach the Node in its config frame', async () => {
++ const c = await t.ws('/device', { headers: { Authorization: `Bearer ${t.network.signNode(alexMachine.principal)}` } });
++ const config = await c.waitFor((m) => m.type === 'config');
++ assert.ok(config, 'the machine gets its config');
++ assert.deepStrictEqual(config.roles, ['codes.worker']);
++ // A change while it is connected reaches it at once.
++ await hub({ method: 'PUT', url: `/api/v1/machines/${alexMachine.id}/roles` }, { body: { roles: ['codes.worker', 'desks.host'] } });
++ const again = await c.waitFor((m) => m.type === 'config' && Array.isArray(m.roles) && m.roles.length === 2);
++ assert.ok(again, 'the new roles are pushed');
++ assert.deepStrictEqual(again.roles, ['codes.worker', 'desks.host']);
++ c.close();
++ });
++
++ await check('a machine\'s online state is reported to Network once per change', async () => {
++ // Its own machine (another person's, so the list of alex's machines stays as it was): the reports of the
++ // other machines (a connection above, its close) are not this one's.
++ const own = await machine(sam, { name: 'attic-pi' });
++ const of = (s) => s.body.machine_id === own.id;
++ const c = await t.ws('/device', { headers: { Authorization: `Bearer ${t.network.signNode(own.principal)}` } });
++ await c.waitFor((m) => m.type === 'config');
++ for (let i = 0; i < 40 && !t.network.statuses.some((s) => of(s) && s.body.online === true); i++) await t.wait(25);
++ const online = t.network.statuses.filter((s) => of(s) && s.body.online === true);
++ assert.strictEqual(online.length, 1, 'the machine is reported online once');
++ assert.strictEqual(online[0].body.owner, sam.subject);
++ c.close();
++ for (let i = 0; i < 40 && !t.network.statuses.some((s) => of(s) && s.body.online === false); i++) await t.wait(25);
++ const offline = t.network.statuses.filter((s) => of(s) && s.body.online === false);
++ assert.strictEqual(offline.length, 1, 'the machine is reported offline once');
++ });
++
++ await check('revoking a machine ends it for good', async () => {
++ const r = await hub({ method: 'POST', url: `/api/v1/machines/${alexMachine.id}/revoke` }, { body: {} });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.ok(t.network.revokes.includes(alexMachine.principal), 'the node principal on Network is revoked too');
++ const row = await t.db.maybe('SELECT revoked_at FROM devices WHERE id = $1', [alexMachine.id]);
++ assert.ok(row.revoked_at, 'the device is revoked');
++ const after = await t.call('GET', '/api/v1/machines', { user: alex });
++ assert.deepStrictEqual(after.json.machines.map((m) => m.id), [], 'a revoked machine is not listed');
++ });
++
++ await t.close();
++ done();
++})();