openvibers/openvibe.bot Commits

Bot: a person's own machines that run their jobs (/machines, worker offers to Network, only the owner's jobs reach them) (#98)

OP OpenVibers committed 30 days ago · 83b4bd5ef4 · 15 files, +397 −21

Plan T14/T16: a person's own machine runs jobs for them, and only them. This is Bot's part of the chain: it holds the machines' link, so it reports their worker offers and has the last word on what reaches a machine. After Contracts#118 and Network#177; Run and Codes follow.

**Why**
- The Run → Bot → Node job chain has never carried a job. Nobody reported a Node's worker to Network (zero offers), so Run could place nothing.
- Every Bot device had to serve a robot, so a coding machine had no way to pair.

**A machine that serves no robot** (migration 0013, expand)
- A Network pairing with ref `worker:<owner>` binds a device with no robot, kind `server`, and the new `devices.owner_subject`.
- The ref must name the pairing's own owner; anything else is `bot.node_not_bound`.
- The bind answers with no robot or video, and the Node only needs the device id.

**/machines**
- Lists the person's machines that run jobs: their worker machines, and their robots' machines that have a job worker. Each shows online, the job classes and the robot served.
- "Add a machine" mints a one-time Network pairing and shows `curl -fsSL …/install | sh -s -- --network … --pairing pair_… --code … --worker`. The installer's `--worker` comes in the Node PR that follows.
- "Remove" revokes the device here and on Network and closes its link.
- Form posts are same-site only; "Your machines" is linked from "Your robots".

**Offers to Network** (`server/jobs/offers.js`)
- Every live device with a job worker becomes a `platform.resource-offer@1`: kind `node`, `trust: user-owned`, `owner` (the device's own, else its robot's), `worker:<class>` per class, `up` while connected and `down` otherwise.
- Bot reports the whole set as source `bot` (`network.resource.report`), once a minute and two seconds after a device comes, goes, is revoked or changes what it advertises.
- Offers are validated first, since one bad offer would make Network refuse the whole report.
- Nothing is reported without Network credentials; tests inject a reporter.

**Only the owner's jobs**
- The job dispatcher compares the job's requester (`user:usr_…`, as Run names it) with the machine's owner.
- It refuses anyone else's job, a service's, or nobody's with `403 bot.not_owner`, whatever the placer chose.

**Also**
- Pin: openvibe-contracts v0.160.0 → v0.161.0 (an offer may name its owner).
- README and STATUS updated.

**Tests**
- New `worker-machines`:
  - binding with no robot, and refusal of a mismatched ref;
  - the offer's owner, up and down, a robot machine's owner, no offer without a worker, and the whole report;
  - only the owner's jobs (another person, null, a service and a bare subject are each refused);
  - `/machines` sign-in, list, add (the `worker:<owner>` pairing and `--worker`), cross-site refusal, removal by a stranger refused, and removal revoking here and on Network.
- Whole suite: 39/39 files pass.

Changes

ddiff --git a/README.md b/README.md
iindex 7f2b4e8..d528169 100644
---- a/README.md
++++ b/README.md
@@@ -41,6 +41,7 @@ Sitemap entries use the release commit time for these code-backed pages.
## Devices
- 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 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 server-side ONVIF camera (`camera.onvif`) is not installed on a machine: it gets no `--driver` and Bot attaches it itself from `BOT_ONVIF_CAMERAS` (see Deploy), so its panel works as soon as the camera answers.
- The robot's panel is `openvibe.bot/panel/<rob_…>` (`GET /panel/:id`, rendered from the profile; `docs/protocol.md` §3). The device-side bring-up steps for the Adeept ADR036 kit live with the driver, in OpenVibe.Node's `docs/hardware-adeept.md` (T15 step 1).
- A robot can use its own `local.rob_…` profile, stored with that robot and validated against `bot.robot-profile@1` and Bot's capability, driver and widget registries. On the owner's robot page, the Buttons form adds or removes rows and saves each button's name, label, optional keyboard key, cooldown in milliseconds and hold setting. The Video click checkbox enables a separate point cooldown. Only the owner can save or return to the robot's catalogue profile; each change is audited.
@@@ -98,6 +99,7 @@ A person acts on their own robots with a Network user token; a service acts with
- A device credential is a secret: sent only in the `Authorization` header (never a query string), stored hashed, returned once by `pair` or a rotation, and revoking it closes the socket at once.
- A person acts only as themself; naming anyone else is refused, never ignored, and a node token is refused on the person and service routes.
++- A person's machine runs only that person's jobs: the job dispatcher compares the job's requester (`user:usr_…`, as Run names it) with the machine's owner (a worker machine's own owner, else its robot's owner) and refuses anyone else's, a service's or nobody's job with `bot.not_owner`, whatever the placer chose. Its offer to Network names the owner and Network keeps it off the public offers list.
- Pairing codes are one-time and short-lived; the e-stop is latched and only the owner clears it, while `halt` always passes the gate.
- Every command, allowed or refused, is audited; the outbox carries ids, kinds and results — never a credential, a pairing code, a publish key or a WHIP URL.
- A server-side ONVIF camera is reached only at the one URL configured in `BOT_ONVIF_CAMERAS` (http(s), one host, no embedded credentials), with a timeout and no redirects, so a camera cannot steer Bot elsewhere. Its ONVIF user and password are named by secret reference (`username_ref`/`password_ref`), read only when a request is built, sent as a WS-Security digest and never stored, answered or logged.
ddiff --git a/STATUS.json b/STATUS.json
iindex a3b342c..5d661b3 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -5,7 +5,7 @@
"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.160.0",
++ "contracts": "openvibe-contracts v0.161.0",
"sdk": "openvibe-sdk v0.44.0",
"shared": "openvibe-shared v4.1.3",
"packages": [
@@@ -56,7 +56,8 @@
"embed opt-in: robots.embed_public (migration 0005), set only by the owner with POST /robots/:id/embed, web-only (not in /api/v1); BOT_EMBED_ORIGINS is the validated frame-ancestors allow-list for the embeddable panel (default OpenVibe.Live)",
"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"
++ "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)"
],
"updated": "2026-10-14"
}
ddiff --git a/migrations/0013_worker_machines.sql b/migrations/0013_worker_machines.sql
nnew file mode 100644
iindex 0000000..8c5031d
---- /dev/null
++++ b/migrations/0013_worker_machines.sql
@@@ -0,0 +1,5 @@
++-- phase: expand
++-- Plan T14/T16: a person's own machine that runs jobs for them (a worker) and serves no robot. Its owner is stored on
++-- the device; a robot's device keeps NULL here and is owned by its robot's owner.
++ALTER TABLE devices ADD COLUMN owner_subject text;
++CREATE INDEX devices_owner ON devices (owner_subject) WHERE owner_subject IS NOT NULL AND revoked_at IS NULL;
ddiff --git a/package-lock.json b/package-lock.json
iindex fafe825..ecbdd21 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -13,7 +13,7 @@
"dotenv": "^16.4.7",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.160.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.161.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.3",
"pg": "^8.23.0",
@@@ -845,9 +845,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.160.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.160.0",
-- "integrity": "sha512-1hhbfO3js3IRrCH2XDTy6TVo9aPaSqSangyBRq1U2WdMvoToeujiucERZnXQCW8kIYWM6R+Ow5M7jNGi7ySnwQ==",
++ "version": "0.161.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.161.0",
++ "integrity": "sha512-2F1KBisvjjMbLI72G4TLsL1Z5aehb+69/nYQGFtF2tcmCeF5ecNvVJaJLRmHnB1cK/vJt+rxIZssE3xdyoFGrQ==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex 66d6faf..797a196 100644
---- a/package.json
++++ b/package.json
@@@ -25,7 +25,7 @@
"dotenv": "^16.4.7",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.160.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.161.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.3",
"pg": "^8.23.0",
ddiff --git a/server/app.js b/server/app.js
iindex 70f6361..2dcdc90 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -24,7 +24,8 @@ const { http } = require('openvibe-contracts');
const { isLoopbackDirect } = require('openvibe-shared/metrics');
const { loadConfig } = require('./config');
const { openDb } = require('./db');
--const { createKeyProvider, createUserAuth, createNodePrincipals, createIdentity } = require('./network');
++const { createKeyProvider, createUserAuth, createNodePrincipals, createIdentity, createOfferReporter } = require('./network');
++const { createOfferPublisher } = require('./jobs/offers');
const { createOpenRe } = require('./openre/client');
const { createBotOutbox } = require('./events/outbox');
const { createUsageRelay } = require('./jobs/metering');
@@@ -89,6 +90,12 @@ function createApp(opts = {}) {
// One jobs service for the device socket and the jobs API, so both see the same stdout rings.
const jobs = createJobs({ db: () => domain.db, hub, usage, log, now });
hub.bindJobs(jobs);
++ // The worker offers of the machines connected here, reported to Network for Run's placement (server/jobs/offers.js);
++ // started by server/index.js with the other jobs. Without Network credentials nothing is reported.
++ const offerReporter = opts.offerReporter !== undefined ? opts.offerReporter
++ : (config.oauth.clientSecret ? createOfferReporter(config, { fetchImpl }) : null);
++ const offers = createOfferPublisher({ db, hub, reporter: offerReporter, log, now });
++ domain.devices.onChange(() => offers.poke());
const sim = opts.sim || createSimulator({ config, domain, hub, now, log });
// Server-side ONVIF cameras (server/onvif): the same in-process attach as the simulator, but with a real
// kind 'server' device row and an outbound ONVIF request per ptz command.
@@@ -165,7 +172,7 @@ function createApp(opts = {}) {
return res.status(500).type('text/plain').send('Something went wrong\n');
});
-- Object.assign(app.locals, { config, db, valkey, domain, keys, outbox, usage, hub, jobs, sim, onvif, userAuth, metrics, submissions, routines, webhooks });
++ Object.assign(app.locals, { config, db, valkey, domain, keys, outbox, usage, hub, jobs, offers, sim, onvif, userAuth, metrics, submissions, routines, webhooks });
return app;
}
ddiff --git a/server/domain/index.js b/server/domain/index.js
iindex c0a706e..19bfd5e 100644
---- a/server/domain/index.js
++++ b/server/domain/index.js
@@@ -19,6 +19,8 @@ const {
} = require('../util');
const { getProfile, validateProfile, reservedKeyReason } = require('../profiles');
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}$/;
const DEFAULT_ALLOW = {
owner: ['drive', 'actuator', 'ptz', 'say', 'display', 'button', 'point', 'halt'],
@@@ -69,6 +71,12 @@ function noteToHz(note) {
function createDomain({ db, config, outbox, link = null, nodes = null, openre = null, webhooks = null, onEvent = null, now = () => Date.now(), log = console }) {
++ // Listeners told when a device appears, goes on or offline, changes what it advertises or is revoked (the worker
++ // offers Bot reports to Network follow these, server/jobs/offers.js).
++ 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 */ } } };
++ const workerRef = (subject) => `worker:${subject}`;
// ── Presenters ────────────────────────────────────────────────────────────────────────────────
const presentRobot = (r) => (r ? {
id: r.id, name: r.name, profile_id: r.profile_id, profile_version: r.profile_version,
@@@ -589,6 +597,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
if (!d) fail(404, 'bot.device_not_found', 'no such device');
const at = iso(now());
const updated = await db.maybe('UPDATE devices SET revoked_at = $2, credential_prev_hash = NULL, prev_valid_until = NULL, updated_at = $2 WHERE id = $1 RETURNING *', [id, at]);
++ changed();
return updated;
}
@@@ -612,18 +621,56 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
if (!p || p.principal !== principalId) refuse('Network knows no such machine paired for Bot');
if (p.status !== 'active') refuse(`the machine is ${p.status} on Network`);
if (!p.paired_for || p.paired_for.service !== 'bot') refuse('the machine was not paired for Bot');
-- const robot = isRobotId(p.paired_for.ref) ? await getRobot(p.paired_for.ref) : null;
-- if (!robot || !p.owner || p.owner.kind !== 'user' || p.owner.subject !== robot.owner_subject) refuse("the machine was not paired for one of its owner's robots");
++ // A worker machine (plan T14/T16) runs its owner's own jobs and serves no robot: its pairing names the owner
++ // (ref `worker:<owner>`), and the device keeps that owner.
++ const worker = !!(p.owner && p.owner.kind === 'user' && USER_SUBJECT.test(String(p.owner.subject)) && p.paired_for.ref === workerRef(p.owner.subject));
++ const robot = !worker && isRobotId(p.paired_for.ref) ? await getRobot(p.paired_for.ref) : null;
++ if (!worker && (!robot || !p.owner || p.owner.kind !== 'user' || p.owner.subject !== robot.owner_subject)) refuse("the machine was not paired for one of its owner's robots");
const at = iso(now());
const name = typeof p.name === 'string' ? storable(p.name).trim().slice(0, 80) || null : null;
// Two first connections at once: the unique index lets one insert win, and the other reads it.
const inserted = await db.maybe(
-- `INSERT INTO devices (id, robot_ids, name, kind, agent_version, drivers, capabilities, credential_hash, node_principal, created_at, updated_at)
-- VALUES ($1, $2::jsonb, $3, 'onboard', NULL, '[]'::jsonb, '{}'::jsonb, NULL, $4, $5, $5)
++ `INSERT INTO devices (id, robot_ids, name, kind, agent_version, drivers, capabilities, credential_hash, node_principal, owner_subject, created_at, updated_at)
++ VALUES ($1, $2::jsonb, $3, $4, NULL, '[]'::jsonb, '{}'::jsonb, NULL, $5, $6, $7, $7)
ON CONFLICT (node_principal) WHERE node_principal IS NOT NULL AND revoked_at IS NULL DO NOTHING RETURNING *`,
-- [prefixedId('dev', now()), JSON.stringify([robot.id]), name, principalId, at]);
++ [prefixedId('dev', now()), JSON.stringify(worker ? [] : [robot.id]), name, worker ? 'server' : 'onboard', principalId,
++ worker ? p.owner.subject : null, at]);
++ changed();
return inserted || db.maybe('SELECT * FROM devices WHERE node_principal = $1 AND revoked_at IS NULL', [principalId]);
}
++
++ // ── Machines that run their owner's jobs (plan T14/T16) ────────────────────────────────────────
++ /**
++ * A one-time code for `ownerSubject` to add a machine that runs their own jobs (Network pairing, ref
++ * `worker:<owner>`; Network keeps one live code per ref, so a new code replaces an unused one). The installer
++ * command turns the Node's job worker on (`--worker`). → { code, expires_at, pairing_id, installer }
++ */
++ async function mintMachine(ownerSubject, installerUrl) {
++ if (!nodes) fail(503, 'bot.network_unavailable', 'Network pairing is not configured here');
++ const p = await nodes.pair({ subject: ownerSubject, ref: workerRef(ownerSubject) });
++ if (!p || typeof p.pairing_id !== 'string' || typeof p.code !== 'string') fail(503, 'bot.network_unavailable', 'Network answered no pairing code');
++ const url = installerUrl || config.installer.scriptUrl;
++ return {
++ code: p.code, expires_at: p.expires_at, pairing_id: p.pairing_id,
++ installer: `curl -fsSL ${url} | sh -s -- --network ${config.network.url} --pairing ${p.pairing_id} --code ${p.code} --worker`,
++ };
++ }
++ /** The person's live machines that run jobs: their worker machines, and their robots' machines that have a job worker. */
++ async function listMachines(subject) {
++ return db.many(
++ `SELECT d.* FROM devices d
++ WHERE d.revoked_at IS NULL AND (d.owner_subject = $1 OR (d.capabilities -> 'worker' IS NOT NULL AND EXISTS (
++ SELECT 1 FROM robots r WHERE r.owner_subject = $1 AND d.robot_ids @> jsonb_build_array(r.id))))
++ ORDER BY d.created_at DESC`, [subject]);
++ }
++ /** Whose machine a device is: a worker machine's owner, else its (first) robot's owner; null when neither. */
++ async function ownerOf(device) {
++ if (!device) return null;
++ if (device.owner_subject) return device.owner_subject;
++ const robotId = json(device.robot_ids, [])[0];
++ const robot = robotId ? await getRobot(robotId) : null;
++ return robot ? robot.owner_subject : null;
++ }
/**
* Issue (or re-issue) a device's WHIP publish key, a new ingest key of its robot's OpenRestream stream: the new
* key replaces the old one, which stops working. → { device, publish_key, whip_url?, profile }, the key
@@@ -647,6 +694,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
}
/** Persist what a Network-paired device declares (kind, drivers, capabilities, agent_version): only the given fields. */
async function updateDeclared(id, { kind, drivers, capabilities, agent_version: agentVersion }) {
++ if (capabilities) changed();
return db.maybe(
`UPDATE devices SET kind = COALESCE($2, kind), drivers = COALESCE($3::jsonb, drivers), capabilities = COALESCE($4::jsonb, capabilities),
agent_version = CASE WHEN $5 THEN $6 ELSE agent_version END, updated_at = $7
@@@ -663,6 +711,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
async function touchSeen(id) { await db.query('UPDATE devices SET last_seen = $2 WHERE id = $1', [id, iso(now())]); }
async function setOnline(device, online) {
++ changed();
const robotIds = json(device.robot_ids, []);
const at = iso(now());
await db.tx(async (t) => {
@@@ -1030,7 +1079,8 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
localProfile, saveLocalProfile, useCatalogueProfile },
members: { roleOf, add: addOperator, remove: removeOperator, list: listOperators },
pairing: { create: createPairingCode, redeem, prune: prunePairingCodes, installerCommand, driverForProfile, whipUrl },
-- devices: { byCredential, bindNode, issuePublishKey, revokeVideo, updateDeclared, revokeNode, ensureServerDevice, get: getDevice, listForRobot: listDevicesForRobot, rotate: rotateDevice, revoke: revokeDevice, touchSeen, setOnline },
++ devices: { byCredential, bindNode, issuePublishKey, revokeVideo, updateDeclared, revokeNode, ensureServerDevice, get: getDevice, listForRobot: listDevicesForRobot, rotate: rotateDevice, revoke: revokeDevice, touchSeen, setOnline, ownerOf, onChange },
++ machines: { mint: mintMachine, list: listMachines, workerRef },
streaming: { get: streaming, set: setStreaming },
video: { live: liveVideo },
estop: { set: setEstop, clear: clearEstop },
ddiff --git a/server/index.js b/server/index.js
iindex 902180c..dc052c4 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -33,7 +33,7 @@ async function main() {
const valkey = createValkey({ url: config.valkey.url, prefix: config.valkey.prefix });
const app = createApp({ config, db, valkey, registry });
-- const { domain, keys, outbox, usage, hub, sim, onvif, routines, webhooks } = app.locals;
++ const { domain, keys, outbox, usage, hub, offers, sim, onvif, routines, webhooks } = app.locals;
keys.start().catch(() => { /* logged by the JWKS client */ });
const timers = [];
@@@ -46,6 +46,7 @@ async function main() {
usage.start();
routines.start(); // scheduled routines (server/routines.js)
webhooks.start(); // robot webhook deliveries (server/webhooks.js)
++ offers.start(); // the worker offers of the machines connected here, to Network (server/jobs/offers.js)
}
const server = app.listen(config.port, config.host, () => {
@@@ -82,6 +83,7 @@ async function main() {
() => timers.forEach(clearInterval),
() => routines.stop(),
() => webhooks.stop(),
++ () => offers.stop(),
() => keys.stop(),
() => sim.stopAll(),
() => onvif.stopAll(),
ddiff --git a/server/jobs/dispatch.js b/server/jobs/dispatch.js
iindex c0228af..c021484 100644
---- a/server/jobs/dispatch.js
++++ b/server/jobs/dispatch.js
@@@ -37,7 +37,14 @@ function advertisedClasses(capabilities) {
return worker && Array.isArray(worker.runtime_classes) ? worker.runtime_classes.filter((c) => typeof c === 'string') : [];
}
const canonical = (v) => JSON.stringify(v, (k, x) => (x && typeof x === 'object' && !Array.isArray(x) ? Object.fromEntries(Object.keys(x).sort().map((key) => [key, x[key]])) : x));
--const liveDevice = (db, id) => db.maybe('SELECT id, capabilities FROM devices WHERE id = $1 AND revoked_at IS NULL', [id]);
++const liveDevice = (db, id) => db.maybe('SELECT id, capabilities, robot_ids, owner_subject FROM devices WHERE id = $1 AND revoked_at IS NULL', [id]);
++/** Whose machine it is: a worker machine's owner, else its robot's owner (every device here is a person's own). */
++async function ownerOfDevice(db, device) {
++ if (device.owner_subject) return device.owner_subject;
++ const robotId = json(device.robot_ids, [])[0];
++ const robot = robotId ? await db.maybe('SELECT owner_subject FROM robots WHERE id = $1', [robotId]) : null;
++ return robot ? robot.owner_subject : null;
++}
/**
* Validate `job` against platform.job@1, store it for device `nodeId` and send it if the device is connected
@@@ -53,6 +60,12 @@ async function dispatch(db, nodeId, job, { link, project = null, subject = null,
const device = await liveDevice(db, nodeId);
if (!device) fail(404, 'bot.device_not_found', 'no such device, or it is revoked');
if (!advertisedClasses(device.capabilities).includes(job.class)) fail(409, 'bot.class_unadvertised', `the device does not advertise the runtime class ${job.class}`);
++ // A person's machine runs only that person's own jobs (contracts 0.161.0, ADR-046): Run names the job's requester
++ // as `subject`, and anyone else's job, or a job for nobody, is refused here whatever the placer chose.
++ // `subject` is a subject reference (`user:usr_…`, how Run formats a person); a service's or nobody's job never runs.
++ const owner = await ownerOfDevice(db, device);
++ const person = typeof subject === 'string' && /^user:usr_/.test(subject) ? subject.slice('user:'.length) : null;
++ if (!owner || person !== owner) fail(403, 'bot.not_owner', "a person's machine runs only that person's own jobs");
const { row, created } = await store.put(db, { job, nodeId, project, subject, provider, at: now() });
if (!created && (row.node_id !== nodeId || canonical(row.job) !== canonical(job)
|| row.project_id !== project || row.subject !== subject || row.provider !== provider)) {
ddiff --git a/server/jobs/offers.js b/server/jobs/offers.js
nnew file mode 100644
iindex 0000000..f8d7e04
---- /dev/null
++++ b/server/jobs/offers.js
@@@ -0,0 +1,99 @@
++'use strict';
++/**
++ * The worker offers Bot reports to Network (plan T14/T16, contracts 0.161.0, ADR-046). Every live device that runs a
++ * job worker (OpenVibe.Node status.capabilities.worker.runtime_classes) is a person's own machine: one
++ * platform.resource-offer@1 per device, kind `node`, trust `user-owned`, naming its owner, advertising
++ * `worker:<class>` for each class, `up` while the device is connected and `down` otherwise. The set is reported whole
++ * as source `bot` (Network marks an offer missing from a later report down), once a minute and a moment after a device
++ * comes or goes or changes what it advertises.
++ *
++ * createOfferPublisher({ db, hub, reporter, log, now, intervalMs, settleMs }) → { build, publish, start, stop, poke }
++ *
++ * An offer that does not match the contract is left out (Network would refuse the whole report for one bad offer).
++ * Without a reporter (no Network credentials) nothing is reported.
++ */
++const { validate } = require('openvibe-contracts');
++const { json } = require('../util');
++
++const CONTRACT = 'platform.resource-offer@1';
++const CLASSES = new Set(['function', 'code', 'browser', 'linux', 'desktop', 'gpu']);
++
++function createOfferPublisher({ db, hub, reporter = null, log = console, now = () => Date.now(), intervalMs = 60_000, settleMs = 2_000 }) {
++ let timer = null;
++ let soon = null;
++ let running = null;
++ let again = false;
++
++ /** The offers of every live worker device, owner and connection included. */
++ async function build() {
++ const rows = await db.many(
++ `SELECT d.id, d.capabilities, d.owner_subject, r.owner_subject AS robot_owner
++ FROM devices d LEFT JOIN robots r ON r.id = d.robot_ids ->> 0
++ WHERE d.revoked_at IS NULL AND d.capabilities -> 'worker' IS NOT NULL
++ ORDER BY d.id`);
++ const at = new Date(now()).toISOString();
++ const offers = [];
++ for (const row of rows) {
++ const worker = json(row.capabilities, {}).worker || {};
++ const classes = (Array.isArray(worker.runtime_classes) ? worker.runtime_classes : []).filter((c) => CLASSES.has(c));
++ const owner = row.owner_subject || row.robot_owner;
++ if (!classes.length || !owner) continue;
++ const offer = {
++ offer_id: `node:${row.id}`, kind: 'node', node_id: row.id, provider: 'self', region: 'home',
++ trust: 'user-owned', owner,
++ capabilities: classes.map((c) => `worker:${c}`),
++ health: { status: hub && hub.isOnline(row.id) ? 'up' : 'down', checked_at: at },
++ pricing: { model: 'free-allowance', marginal_usd_per_unit: 0 },
++ updated_at: at,
++ };
++ const v = validate(CONTRACT, offer);
++ if (!v.valid) {
++ log.warn(`[Bot] offers: ${row.id} does not match ${CONTRACT}, left out`);
++ continue;
++ }
++ offers.push(offer);
++ }
++ return offers;
++ }
++
++ /** Report the whole set now (one report at a time; a change during one reports again after it). */
++ async function publish() {
++ if (!reporter) return null;
++ if (running) { again = true; return running; }
++ running = (async () => {
++ try {
++ return await reporter.report(await build());
++ } catch (e) {
++ log.warn(`[Bot] offers report: ${e.message}`);
++ return null;
++ } finally {
++ running = null;
++ if (again) { again = false; poke(); }
++ }
++ })();
++ return running;
++ }
++
++ /** A device changed: report a moment later (changes in a burst are reported once). Only once started. */
++ function poke() {
++ if (!reporter || !timer || soon) return;
++ soon = setTimeout(() => { soon = null; void publish(); }, settleMs);
++ if (soon.unref) soon.unref();
++ }
++
++ function start() {
++ if (!reporter || timer) return;
++ timer = setInterval(() => { void publish(); }, intervalMs);
++ if (timer.unref) timer.unref();
++ poke();
++ }
++ function stop() {
++ if (timer) clearInterval(timer);
++ if (soon) clearTimeout(soon);
++ timer = null;
++ soon = null;
++ }
++ return { build, publish, start, stop, poke };
++}
++
++module.exports = { createOfferPublisher };
ddiff --git a/server/network.js b/server/network.js
iindex ff87cd6..184d273 100644
---- a/server/network.js
++++ b/server/network.js
@@@ -87,6 +87,36 @@ function createNodePrincipals(config, { fetchImpl = globalThis.fetch } = {}) {
};
}
++/**
++ * Network's resource registry (plan T14, contracts 0.161.0): Bot reports the worker offers of the machines connected
++ * to it — each a user-owned offer naming its owner — as the complete set for source `bot`
++ * (POST /internal/resources/report, network.resource.report); an offer missing from a later report is marked down.
++ * report(offers) → Network's answer { source, offers, marked_down }
++ * A failure throws 503 bot.network_unavailable.
++ */
++function createOfferReporter(config, { fetchImpl = globalThis.fetch } = {}) {
++ const tokens = serviceAuth.createTokenClient({
++ tokenUrl: `${config.network.internalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
++ audience: 'openvibe.network', scope: 'network.resource.report', fetchImpl,
++ });
++ async function report(offers) {
++ let res;
++ try {
++ res = await fetchImpl(`${config.network.internalUrl}/internal/resources/report`, {
++ method: 'POST', headers: { ...(await tokens.authHeaders()), Accept: 'application/json', 'Content-Type': 'application/json' },
++ body: JSON.stringify({ source: 'bot', offers }), signal: AbortSignal.timeout(8000),
++ });
++ } catch (e) {
++ throw new BotError(503, 'bot.network_unavailable', `Network did not answer the offers report: ${e.message}`);
++ }
++ if (res.status === 401) tokens.invalidate();
++ const out = await res.json().catch(() => null);
++ if (!res.ok || !out) throw new BotError(503, 'bot.network_unavailable', `Network answered ${res.status} for the offers report${out && out.error ? `: ${out.error}` : ''}`);
++ return out;
++ }
++ return { report };
++}
++
/**
* Network's identity resolution for people (usernames ↔ subjects), with Bot's own svc:bot token
* (audience openvibe.network, identity.subject.resolve — Network grants Bot this).
@@@ -157,4 +187,4 @@ function createIdentity(config, { fetchImpl = globalThis.fetch, now = () => Date
return { byUsername, names };
}
--module.exports = { createKeyProvider, createUserAuth, createNodePrincipals, createIdentity };
++module.exports = { createKeyProvider, createUserAuth, createNodePrincipals, createIdentity, createOfferReporter };
ddiff --git a/server/web/directory.js b/server/web/directory.js
iindex bed88ef..87cef8a 100644
---- a/server/web/directory.js
++++ b/server/web/directory.js
@@@ -143,7 +143,7 @@ function renderDirectory({ config, listed = [], mine = null, models = [], signed
const yours = !signedIn ? `<section class="dir-sec dir-cta"><h2>Have a robot?</h2>
<p class="muted">Sign in to add it and pair it with one pasted command, or drive a simulated rover first, no account needed.</p>
<p class="dir-actions">${link('/try', 'Try the panel', 'dir-btn dir-primary')} ${link('/auth/login?next=%2Frobots%2Fnew', 'Sign in and add a robot', 'dir-btn')}</p></section>`
-- : `<section class="dir-sec" aria-labelledby="yours-h"><div class="dir-sec-head"><h2 id="yours-h">Your robots</h2>${link('/robots/new', 'Add a robot', 'dir-btn dir-primary')}</div>
++ : `<section class="dir-sec" aria-labelledby="yours-h"><div class="dir-sec-head"><h2 id="yours-h">Your robots</h2>${link('/machines', 'Your machines', 'dir-btn')}${link('/robots/new', 'Add a robot', 'dir-btn dir-primary')}</div>
${mine && mine.length ? `<ul class="dir-list">${mine.map((r) => `<li class="dir-row"><a href="/panel/${esc(r.id)}"><b>${esc(r.name)}</b></a><span class="muted">${esc(r.model_name)}</span>`
+ `${r.listed ? `<a class="dir-pill" href="/r/${esc(r.id)}">Listed</a>` : '<span class="dir-pill dir-quiet">Not listed</span>'}`
+ `<span class="dir-row-actions">${link(`/panel/${r.id}`, 'Panel')} · ${link(`/pair/${r.id}`, 'Pair a device')}</span></li>`).join('')}</ul>`
ddiff --git a/server/web/render.js b/server/web/render.js
iindex 106aa72..f32a852 100644
---- a/server/web/render.js
++++ b/server/web/render.js
@@@ -579,6 +579,41 @@ ${first}
return page(`Pair ${robot.name}`, body, { scripts: ['/panel/panel.js'] });
}
++/**
++ * /machines (plan T14/T16): a person's own machines that run jobs for them, and the command that adds one. A machine
++ * here runs its owner's jobs only (Bot refuses anyone else's), connects out, and keeps its local limits and kill switch.
++ */
++function renderMachinesPage({ machines = [], pairing = null }) {
++ const classesOf = (m) => {
++ const w = (m.capabilities && typeof m.capabilities === 'object' ? m.capabilities : {}).worker || {};
++ return Array.isArray(w.runtime_classes) ? w.runtime_classes : [];
++ };
++ const list = machines.length
++ ? `<ul class="robots">${machines.map((m) => `<li class="robot-card"><span class="robot-name">${icon('plug')}<span>${esc(m.name || m.id)}</span></span>`
++ + `<span class="robot-meta"><span class="pill">${m.online ? 'Online' : 'Offline'}</span>${classesOf(m).map((c) => ` <span class="pill">${esc(c)} jobs</span>`).join('')}`
++ + `${m.agent_version ? ` <span class="pill">Node ${esc(m.agent_version)}</span>` : ''}${m.robot ? ` <span class="pill">Serves ${esc(m.robot)}</span>` : ''}</span>`
++ + `<span class="robot-actions"><form method="post" action="/machines/${esc(m.id)}/remove"><button type="submit" class="button quiet">Remove</button></form></span></li>`).join('')}</ul>`
++ : `<div class="empty">${icon('plug', 'icon empty-icon')}<p class="empty-title">No machines yet.</p><p>Add your computer or a server you own. It runs the jobs you start, such as a coding task on openvibe.codes, and nobody else's.</p></div>`;
++ const add = pairing
++ ? `<ol class="steps"><li class="card"><h2>Run this on the machine</h2>
++<pre class="installer"><code data-copy-source>${esc(pairing.installer)}</code></pre>
++<button type="button" class="copy" data-copy>${icon('copy')}<span data-copy-label>Copy the command</span></button>
++<p class="muted">Linux with systemd. It installs OpenVibe Node, turns its job worker on (jobs run as a separate user, in a sandbox, with the limits you set) and pairs the machine with your account. For coding jobs the machine also needs Node.js 22 or newer and git, and a coding agent: Claude Code, Codex, OpenCode, or a DeepSeek key.</p></li>
++<li class="card"><h2>Or enter the pairing code</h2><p><strong class="code">${esc(pairing.code)}</strong></p>
++<p class="muted">Valid until <time datetime="${esc(pairing.expires_at)}">${esc(pairing.expires_at)}</time>. It works once.</p></li></ol>
++<p class="muted">Reload this page once the installer says it is paired: the machine shows up above.</p>`
++ : `<form class="add-robot card" method="post" action="/machines"><h2>Add a machine</h2>
++<p class="muted">You get a one-line install command with a one-time code.</p><button type="submit" class="primary">Add a machine</button></form>`;
++ const body = `${topbar('<span class="crumb">Machines</span>')}
++<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>
++${list}
++${add}
++</main>`;
++ return page('Your machines', body, { scripts: pairing ? ['/panel/panel.js'] : [], description: 'Your own machines that run your jobs on OpenVibe.', canonical: '/machines' });
++}
++
/**
* /device/phone: the page that makes a phone the robot's device (public/phone-device.js). Its setup form pairs it (the
* QR code fills in the robot and code); once running, the screen is the robot's face, with the link and camera state
@@@ -613,4 +648,4 @@ function renderPhonePage() {
return page('This phone is a robot', body, { scripts: ['/panel/phone-device.js'], bodyClass: 'phone-page', description: 'Turn a phone into a robot on OpenVibe.Bot from its browser: its camera, voice and face, with nothing to install.', canonical: '/device/phone' });
}
--module.exports = { page, renderPanel, renderPhonePage, panelScripts, isSim, TRY_DESCRIPTION, renderEmbedRefused, renderDrivePage, renderWidget, renderRobotsPage, renderPairingPage, camerasOf, esc };
++module.exports = { page, renderPanel, renderPhonePage, renderMachinesPage, panelScripts, isSim, TRY_DESCRIPTION, renderEmbedRefused, renderDrivePage, renderWidget, renderRobotsPage, renderPairingPage, camerasOf, esc };
ddiff --git a/server/web/routes.js b/server/web/routes.js
iindex e2182a1..8be95f9 100644
---- a/server/web/routes.js
++++ b/server/web/routes.js
@@@ -36,7 +36,7 @@ const { getProfile, listProfiles, loadProfiles } = require('../profiles');
const { frameAncestors } = require('../config');
const { BotError } = require('../util');
const kits = require('../kits');
--const { renderPanel, renderEmbedRefused, renderDrivePage, renderPairingPage, renderPhonePage } = require('./render');
++const { renderPanel, renderEmbedRefused, renderDrivePage, renderPairingPage, renderPhonePage, renderMachinesPage } = require('./render');
const { renderDocsIndex, renderDriversPage, renderProfilesPage, renderAutomationPage } = require('./docs');
const { checkProfile } = require('../profiles');
const { renderHome, HOME_CSP } = require('./home');
@@@ -423,6 +423,34 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
const purpose = String(req.headers['sec-purpose'] || req.headers.purpose || req.headers['x-moz'] || '');
return (site && site !== 'same-origin' && site !== 'none') || /prefetch|prerender/i.test(purpose);
};
++ // ── The person's own machines that run their jobs (plan T14/T16) ──
++ const machineRows = async (subject) => Promise.all((await domain.machines.list(subject)).map(async (d) => {
++ const robotId = Array.isArray(d.robot_ids) ? d.robot_ids[0] : null;
++ const robot = robotId ? await domain.robots.get(robotId) : null;
++ return { ...d, online: !!(hub && hub.isOnline(d.id)), robot: robot ? robot.name : null };
++ }));
++ r.get('/machines', frameCsp, page(async (req, res) => {
++ const me = requireUser(req);
++ res.setHeader('Vary', 'Cookie');
++ res.type('html').send(renderMachinesPage({ machines: await machineRows(me.subject) }));
++ }));
++ r.post('/machines', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {
++ const me = requireUser(req);
++ if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');
++ const pairing = await domain.machines.mint(me.subject);
++ res.status(201).type('html').send(renderMachinesPage({ machines: await machineRows(me.subject), pairing }));
++ }));
++ r.post('/machines/:id/remove', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {
++ const me = requireUser(req);
++ if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');
++ const mine = (await domain.machines.list(me.subject)).find((d) => d.id === req.params.id);
++ if (!mine) throw new BotError(404, 'bot.device_not_found', 'no such machine of yours');
++ await domain.devices.revoke(mine.id);
++ if (mine.node_principal) await domain.devices.revokeNode(mine.node_principal).catch((e) => log.warn(`[Bot] machine ${mine.id} on Network: ${e.message}`));
++ if (hub) hub.closeDevice(mine.id, 'revoked');
++ res.redirect(303, '/machines');
++ }));
++
r.get('/pair/:id', ...manage, page(async (req, res) => {
const me = requireUser(req);
if (crossSiteOrPrefetch(req)) return res.redirect(303, '/robots');
ddiff --git a/test/worker-machines.test.js b/test/worker-machines.test.js
nnew file mode 100644
iindex 0000000..b2a45f3
---- /dev/null
++++ b/test/worker-machines.test.js
@@@ -0,0 +1,104 @@
++'use strict';
++// A person's own machine that runs their jobs (plan T14/T16, contracts 0.161.0): a Network pairing with ref
++// `worker:<owner>` binds a machine that serves no robot and keeps its owner; Bot reports every machine with a job
++// worker to Network as a user-owned offer naming its owner (up while connected, down once it goes); a job reaches a
++// machine only for its owner; /machines lists the person's machines, adds one with a one-time `--worker` command and
++// removes one.
++const assert = require('assert');
++const { ids } = require('openvibe-contracts');
++const { boot, check, done } = require('./helpers/app');
++
++(async () => {
++ const reports = [];
++ const reporter = { report: async (offers) => { reports.push(offers); return { source: 'bot', offers: offers.length, marked_down: 0 }; } };
++ const t = await boot({ env: { BOT_ROUTINE_TICK_MS: '0', BOT_WEBHOOK_TICK_MS: '0' }, appOpts: { offerReporter: reporter } });
++ const alex = t.network.newUser('alex');
++ const bob = t.network.newUser('bob');
++ const { dispatch } = require('../server/jobs/dispatch');
++ const poll = async (fn, ms = 8000) => { const end = Date.now() + ms; while (Date.now() < end) { const v = await fn(); if (v) return v; await t.wait(50); } return null; };
++ const cookie = (user) => ({ Cookie: `ov_token=${t.network.signUser(user)}` });
++ const form = (p, user, headers = {}) => fetch(t.base + p, { method: 'POST', redirect: 'manual', body: '', headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...(user ? cookie(user) : {}), ...headers } });
++ let seq = 0;
++ const frame = (type, fields) => ({ v: 1, seq: ++seq, ts: Date.now(), type, ...fields });
++ const offers = t.app.locals.offers;
++
++ /** A Network-paired machine for `ref`, connected, advertising `classes` (none: no job worker). */
++ async function machine(owner, ref, classes = ['code']) {
++ const principal = t.network.addNode({ owner: owner.subject, ref, name: 'desk-pc' });
++ const c = await t.ws('/device', { headers: { Authorization: `Bearer ${t.network.signNode(principal)}` } });
++ const deviceId = (await c.waitFor((m) => m.type === 'hello')).device_id;
++ if (classes.length) {
++ c.send(frame('status', { capabilities: { worker: { capabilities: classes.map((x) => `worker:${x}`), runtime_classes: classes } } }));
++ await poll(async () => { const d = await t.db.maybe('SELECT capabilities FROM devices WHERE id = $1', [deviceId]); return d && d.capabilities.worker; });
++ }
++ return { principal, c, deviceId };
++ }
++
++ let mine;
++ await check('a worker pairing binds a machine with no robot, owned by whoever paired it', async () => {
++ mine = await machine(alex, `worker:${alex.subject}`);
++ const row = await t.db.maybe('SELECT * FROM devices WHERE id = $1', [mine.deviceId]);
++ assert.deepStrictEqual([row.robot_ids, row.owner_subject, row.kind], [[], alex.subject, 'server']);
++ assert.strictEqual(await t.domain.devices.ownerOf(row), alex.subject);
++ // Someone else's worker ref with this owner is refused: the ref must name the pairing's own owner.
++ const stray = t.network.addNode({ owner: alex.subject, ref: `worker:${bob.subject}` });
++ const bound = await t.call('POST', '/api/v1/devices/bind', { token: t.network.signNode(stray), body: {} });
++ assert.strictEqual(bound.status, 403, bound.text);
++ assert.strictEqual(bound.json.code, 'bot.node_not_bound');
++ });
++
++ await check('its worker offer is user-owned and names its owner: up while connected, down once it goes', async () => {
++ const built = await offers.build();
++ const offer = built.find((o) => o.node_id === mine.deviceId);
++ assert.ok(offer, JSON.stringify(built));
++ assert.deepStrictEqual([offer.kind, offer.trust, offer.owner, offer.capabilities, offer.health.status],
++ ['node', 'user-owned', alex.subject, ['worker:code'], 'up']);
++ // A robot's machine with a job worker is its robot owner's; a machine with no worker offers nothing.
++ const { robot } = await t.robot(bob);
++ const robots = await machine(bob, robot.id, ['function']);
++ const quiet = await machine(bob, `worker:${bob.subject}`, []);
++ const again = await offers.build();
++ assert.strictEqual(again.find((o) => o.node_id === robots.deviceId).owner, bob.subject);
++ assert.strictEqual(again.find((o) => o.node_id === quiet.deviceId), undefined);
++ quiet.c.close();
++ robots.c.close();
++ // Reported whole, as Network's resource registry takes it.
++ await offers.publish();
++ assert.deepStrictEqual(reports.at(-1).map((o) => o.offer_id).sort(), again.map((o) => o.offer_id).sort());
++ });
++
++ await check('a machine runs only its owner’s jobs', async () => {
++ const job = (over = {}) => ({ id: `job_${ids.ulid()}`, class: 'code', artifact: { name: 'openvibe-codes', version: '0.3.0' }, args: {}, ttl_ms: 60000, limits: { wall_ms: 30000, cpu_ms: 30000, mem_bytes: 268435456 }, ...over });
++ const project = `prj_${ids.ulid()}`;
++ const send = (subject) => dispatch(t.db, mine.deviceId, job(), { link: t.hub, project, subject });
++ const ok = await send(`user:${alex.subject}`);
++ assert.strictEqual(ok.sent, true);
++ for (const subject of [`user:${bob.subject}`, null, 'service:watch', alex.subject]) {
++ await assert.rejects(send(subject), (e) => e.status === 403 && e.code === 'bot.not_owner', String(subject));
++ }
++ });
++
++ await check('/machines lists them, adds one with a one-time --worker command, and removes one', async () => {
++ const anon = await fetch(`${t.base}/machines`, { redirect: 'manual' });
++ assert.strictEqual(anon.status, 302, 'signed out: to the sign-in');
++ const page = await (await fetch(`${t.base}/machines`, { headers: cookie(alex) })).text();
++ assert.ok(page.includes('Your machines') && page.includes('desk-pc') && page.includes('Online') && page.includes('code jobs'), page);
++ assert.ok(!page.includes(bob.subject));
++ const added = await form('/machines', alex);
++ assert.strictEqual(added.status, 201);
++ const html = await added.text();
++ const pairing = t.network.pairings.at(-1);
++ assert.strictEqual(pairing.body.ref, `worker:${alex.subject}`);
++ assert.ok(html.includes('--worker') && html.includes('--pairing pair_'), html);
++ assert.strictEqual((await form('/machines', alex, { Origin: 'https://evil.test' })).status, 403, 'no cross-site post');
++ assert.strictEqual((await form(`/machines/${mine.deviceId}/remove`, bob)).status, 404, 'not bob’s');
++ const removed = await form(`/machines/${mine.deviceId}/remove`, alex);
++ assert.strictEqual(removed.status, 303);
++ assert.ok((await t.db.maybe('SELECT revoked_at FROM devices WHERE id = $1', [mine.deviceId])).revoked_at);
++ assert.ok(t.network.revokes.includes(mine.principal));
++ assert.strictEqual((await offers.build()).find((o) => o.node_id === mine.deviceId), undefined, 'a removed machine offers nothing');
++ });
++
++ await t.close();
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });