openvibers/openvibe.bot Commits
The machines hub's side: the roles of a machine and its state for Network (plan L34) (#245)
Changes
@@@ -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 BotOpenVibe.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 -->@@@ -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"}nnew file mode 100644@@@ -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;@@@ -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",@@@ -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",@@@ -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, submconst 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, submreturn {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, submres.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).@@@ -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 },nnew file mode 100644@@@ -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 };@@@ -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),};}@@@ -342,6 +342,9 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Datestop_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);@@@ -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>`;@@@ -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() {nnew file mode 100644@@@ -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();++})();
@@ -43,6 +43,7 @@ Sitemap entries use the release commit time for these code-backed pages.
@@ -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`.
- 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.
- **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`).
- **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`).
- **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:
- `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`);
- 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).
@@ -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.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.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.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.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 |
| `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
@@ -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.
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 -->
<!-- versions:start -->
-- openvibe-contracts: v0.223.0
+- openvibe-contracts: v0.261.0
- openvibe-sdk: v0.74.0
- openvibe-sdk: v0.74.0
- openvibe-shared: v4.66.0
- openvibe-shared: v4.66.0
<!-- versions:end -->
<!-- versions:end -->
@@ -5,11 +5,11 @@
@@ -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",
"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,
"port": 4630,
"domain": "openvibe.bot (www -> apex; /device and /control WebSockets; /install)",
"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",
"sdk": "openvibe-sdk v0.74.0",
"shared": "openvibe-shared v4.66.0",
"shared": "openvibe-shared v4.66.0",
"packages": [
"packages": [
- "openvibe-contracts v0.223.0",
+ "openvibe-contracts v0.261.0",
"openvibe-sdk v0.74.0",
"openvibe-sdk v0.74.0",
"openvibe-shared v4.66.0"
"openvibe-shared v4.66.0"
],
],
@@ -19,6 +19,7 @@
@@ -19,6 +19,7 @@
"bot.robot.control",
"bot.robot.control",
"bot.computer.control",
"bot.computer.control",
"bot.machine.ai",
"bot.machine.ai",
+ "bot.machine.manage",
"bot.desk.session",
"bot.desk.session",
"bot.device.connect",
"bot.device.connect",
"bot.job.dispatch",
"bot.job.dispatch",
@@ -61,7 +62,8 @@
@@ -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",
"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",
"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",
"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"
"updated": "2026-10-31"
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,6 @@
@@ -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;
@@ -13,7 +13,7 @@
@@ -13,7 +13,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.66.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -1120,9 +1120,9 @@
@@ -1120,9 +1120,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"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",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -25,7 +25,7 @@
@@ -25,7 +25,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.66.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -44,6 +44,7 @@ const { http } = require('openvibe-contracts');
@@ -44,6 +44,7 @@ const { http } = require('openvibe-contracts');
const { fail, userSubject, isRobotId, json } = require('../util');
const { fail, userSubject, isRobotId, json } = require('../util');
const { createMachineScreen } = require('../machine-screen');
const { createMachineScreen } = require('../machine-screen');
const { getProfile, listProfiles, checkProfile } = require('../profiles');
const { getProfile, listProfiles, checkProfile } = require('../profiles');
+const machineRoles = require('../machine-roles');
const kits = require('../kits');
const kits = require('../kits');
const resourceIndex = require('./resource-index');
const resourceIndex = require('./resource-index');
const resourceControl = require('./resource-control');
const resourceControl = require('./resource-control');
@@ -57,6 +58,8 @@ const CAP = {
@@ -57,6 +58,8 @@ const CAP = {
dispatch: 'bot.job.dispatch',
dispatch: 'bot.job.dispatch',
computer: 'bot.computer.control',
computer: 'bot.computer.control',
ai: 'bot.machine.ai',
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. */
/** 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 = {
@@ -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.
// 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' },
'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.
// 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';
const AI_KIND = 'ai';
@@ -93,6 +100,8 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
@@ -93,6 +100,8 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
const commandControl = limits.actedFor('bot.control', { minute: 120, hour: 1200 },
const commandControl = limits.actedFor('bot.control', { minute: 120, hour: 1200 },
{ minute: config.actorLimits.controlServiceMinute, hour: config.actorLimits.controlServiceHour });
{ minute: config.actorLimits.controlServiceMinute, hour: config.actorLimits.controlServiceHour });
const pair = limits('bot.pair', { minute: 20, hour: 200 });
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 jobs = limits('bot.jobs', { minute: 120, hour: 1200 });
const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
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
@@ -435,6 +444,14 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
return {
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,
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,
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
// 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).
// machine from Network (Codes' fleet, a Run pin) finds this row (Codes C9c).
node_id: device.node_principal || null,
node_id: device.node_principal || null,
@@ -455,20 +472,47 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
@@ -455,20 +472,47 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
} : null,
} : 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.
// 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) => {
r.get('/machines', wrap(async (req, res) => {
const subject = actedFor(req, req.query.owner);
const subject = actedFor(req, req.query.owner);
const svc = req.principal.kind === 'service' ? MACHINE_SERVICES[req.principal.sub] : null;
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 (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
// 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).
// (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`);
if (!apiAuth.granted(req.principal, cap)) fail(403, 'capability.denied', `${cap} not granted`);
}
}
const rows = await domain.machines.list(subject);
const rows = await domain.machines.list(subject);
const mine = svc ? rows.filter((d) => d[svc.flag]) : rows;
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) => {
r.get('/machines/:id/computer', wrap(async (req, res) => {
const { device } = await computerMachine(req);
const { device } = await computerMachine(req);
@@ -552,6 +596,80 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
@@ -552,6 +596,80 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
res.json({ machine: machineOut(updated) });
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 ──
// ── 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.
// 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).
// Every step passes the same gate as a person's command (server/routines.js runSteps).
@@ -18,6 +18,7 @@ const {
@@ -18,6 +18,7 @@ const {
BotError, fail, prefixedId, iso, token, hashSecret, secretEquals, json, text, storable, isRobotId,
BotError, fail, prefixedId, iso, token, hashSecret, secretEquals, json, text, storable, isRobotId,
} = require('../util');
} = require('../util');
const { getProfile, validateProfile, reservedKeyReason } = require('../profiles');
const { getProfile, validateProfile, reservedKeyReason } = require('../profiles');
+const machineRoles = require('../machine-roles');
const { ENVELOPE } = require('../events/outbox');
const { ENVELOPE } = require('../events/outbox');
/** A person's subject (the owner a worker machine's pairing names). */
/** A person's subject (the owner a worker machine's pairing names). */
const USER_SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const USER_SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
@@ -76,6 +77,8 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -76,6 +77,8 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
const changeListeners = new Set();
const changeListeners = new Set();
const onChange = (fn) => { changeListeners.add(fn); return () => changeListeners.delete(fn); };
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 */ } } };
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}`;
const workerRef = (subject) => `worker:${subject}`;
// ── Presenters ────────────────────────────────────────────────────────────────────────────────
// ── Presenters ────────────────────────────────────────────────────────────────────────────────
const presentRobot = (r) => (r ? {
const presentRobot = (r) => (r ? {
@@ -798,6 +801,49 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -798,6 +801,49 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
async function listSharedDesks() {
async function listSharedDesks() {
return db.many('SELECT * FROM devices WHERE revoked_at IS NULL AND shared_desks = true ORDER BY created_at');
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) ──────────────────────────────────────
// ── Desk leases (contracts 0.223.0 bot.desk-lease@1, the Desks plan's D6) ──────────────────────────────────────
const leaseOut = (r) => (r ? {
const leaseOut = (r) => (r ? {
@@ -1329,6 +1375,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -1329,6 +1375,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
pairing: { create: createPairingCode, redeem, prune: prunePairingCodes, installerCommand, driverForProfile, whipUrl, onPaired },
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 },
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,
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 } },
screen: { key: screenKey, liveKey: liveScreenKey, live: screenLive, end: screenEnd, archive: screenArchive } },
deskLeases,
deskLeases,
streaming: { get: streaming, set: setStreaming },
streaming: { get: streaming, set: setStreaming },
new file mode 100644
new file mode 100644
@@ -0,0 +1,54 @@
@@ -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 };
@@ -8,8 +8,10 @@
@@ -8,8 +8,10 @@
* unknown kid). They verify service and node tokens (audience openvibe.bot) and the browser's Network
* unknown kid). They verify service and node tokens (audience openvibe.bot) and the browser's Network
* user JWT, all offline (openvibe-sdk/auth).
* user JWT, all offline (openvibe-sdk/auth).
* nodes Network's node principals (machines paired for Bot): POST /internal/node-pairings (a pairing
* 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
* 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.
* names of subjects the robot's owner added.
*/
*/
@@ -84,6 +86,8 @@ function createNodePrincipals(config, { fetchImpl = globalThis.fetch } = {}) {
@@ -84,6 +86,8 @@ function createNodePrincipals(config, { fetchImpl = globalThis.fetch } = {}) {
get: (id) => call('GET', principal(id)),
get: (id) => call('GET', principal(id)),
revoke: (id) => call('POST', `${principal(id)}/revoke`),
revoke: (id) => call('POST', `${principal(id)}/revoke`),
pair: ({ subject, ref }) => call('POST', '/internal/node-pairings', { owner: { kind: 'user', subject }, ref }),
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),
};
};
}
}
@@ -342,6 +342,9 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -342,6 +342,9 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
estop_latched: robot ? !!robot.estop_latched : false,
estop_latched: robot ? !!robot.estop_latched : false,
// Vibe by voice: the machine listens for its call word only while its owner has voice on.
// 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) } : {}),
...(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). */
/** 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
@@ -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. */
/** The online/offline event and last_seen are a real machine's: a simulator (no devices row) reports neither. */
function reportOnline(conn, online) {
function reportOnline(conn, online) {
if (conn.sim) return Promise.resolve();
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) {
async function bringOnline(conn) {
await reportOnline(conn, true);
await reportOnline(conn, true);
@@ -842,6 +842,7 @@ ${choice('Use the models you run on it', ' --ai', 'If this computer runs Ollama,
@@ -842,6 +842,7 @@ ${choice('Use the models you run on it', ' --ai', 'If this computer runs Ollama,
<main class="page">
<main class="page">
<h1>Your machines</h1>
<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 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}
${list}
${add}
${add}
</main>`;
</main>`;
@@ -6,7 +6,8 @@
@@ -6,7 +6,8 @@
* client-credentials tokens and records every request in tokenCalls ({client_id, audience,
* client-credentials tokens and records every request in tokenCalls ({client_id, audience,
* scope}); node principals as
* scope}); node principals as
* GET /internal/node-principals/:id and POST …/:id/revoke answer them (addNode, signNode,
* 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=
* resolution for people (identity.subject.resolve): GET /internal/identity/resolve?username=
* and POST /internal/identity/resolve-batch, over the users newUser registers; failIdentity
* and POST /internal/identity/resolve-batch, over the users newUser registers; failIdentity
* makes them answer a failure (Network down).
* makes them answer a failure (Network down).
@@ -88,6 +89,9 @@ async function startNetwork() {
@@ -88,6 +89,9 @@ async function startNetwork() {
let pairingFailure = null;
let pairingFailure = null;
const failPairings = (status) => { pairingFailure = status; };
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;
// Identity resolution for people (identity.subject.resolve). `users` is what newUser registered;
// failIdentity(status) makes both routes answer that status instead (Network down).
// failIdentity(status) makes both routes answer that status instead (Network down).
const users = new Map(); // subject → { subject, username, display_name }
const users = new Map(); // subject → { subject, username, display_name }
@@ -146,6 +150,15 @@ async function startNetwork() {
@@ -146,6 +150,15 @@ async function startNetwork() {
pairings.push({ service, body, ...out });
pairings.push({ service, body, ...out });
return send(res, 201, 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);
const np = /^\/internal\/node-principals\/([^/]+)(\/revoke)?$/.exec(req.url);
if (np && (req.method === (np[2] ? 'POST' : 'GET'))) {
if (np && (req.method === (np[2] ? 'POST' : 'GET'))) {
const service = nodeManager(req);
const service = nodeManager(req);
@@ -162,7 +175,7 @@ async function startNetwork() {
@@ -162,7 +175,7 @@ async function startNetwork() {
});
});
const url = await listen(server);
const url = await listen(server);
issuer = url;
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() {
async function startEvents() {
new file mode 100644
new file mode 100644
@@ -0,0 +1,165 @@
@@ -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();
+})();