openvibers/openvibe.bot Commits

Limit web robot and pairing writes per person and stop stray code mints

OP OpenVibers committed 4 Oct 2026 · fb68d5cc80 · 7 files, +94 −27

Requested by finish-f1. Handed to the local harness by the cloud job pr-OpenVibe.Bot-20; done by Claude opus.

Changes

ddiff --git a/README.md b/README.md
iindex 83a9ae9..858d115 100644
---- a/README.md
++++ b/README.md
@@@ -12,6 +12,7 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato
- Devices (the machines that serve a robot) and the credentials that identify them: hashed at rest, rotated and revoked, never readable back.
- Pairing: one-time codes with a short expiry, minted by Bot or, with `BOT_PAIRING_AUTHORITY=network`, by OpenVibe.Network.
- The `/device` and `/control` WebSockets, the command gate and the latched e-stop.
++- The signed-in pages (`server/web/routes.js`): `/robots` to list and add robots, `/pair/:id` for a pairing code and the installer command, and `/panel/:id`, the panel rendered from the profile. A `sim.rover` robot is driven by an in-process simulator (`server/sim`), so the panel works before any hardware exists. It gets no jobs, and it is not counted as a device or reported online in the outbox.
- The command audit (`command_audit`, kept 30 days) and the `bot.*` outbox events.
- Dispatching `platform.job@1` jobs to a paired Node over `/device` (`server/jobs/dispatch.js`, no HTTP route) and metering them per wall-clock second (`run_jobs`, `run_usage_outbox`; docs/protocol.md §1.2).
ddiff --git a/docs/protocol.md b/docs/protocol.md
iindex c1e04ed..f4bb32b 100644
---- a/docs/protocol.md
++++ b/docs/protocol.md
@@@ -403,7 +403,10 @@ routes. Both come before any lookup, so a real robot or device id answers exactl
| `POST /pair` | the one-time code is the credential | `201 { device_id, credential, publish_key, whip_url, robot_id, profile }`; with `BOT_PAIRING_AUTHORITY=network` always `410 bot.pairing_moved` (the `detail` names the Network URL) |
Not in `/api/v1`: `GET /api/health`, `GET /api/ready`, `GET /metrics`, `GET /release.json`, the
--`/auth/*` SSO routes, and `GET /` (a one-line text health placeholder).
++`/auth/*` SSO routes, `GET /` (a one-line text health placeholder), `GET /install` (a 302 to the installer),
++and the signed-in pages `GET|POST /robots`, `GET /pair/:id`, `GET /panel/:id` and `GET /panel/panel.{js,css}`
++(`server/web/routes.js`; adding a robot and minting a code count against the `bot.robot.manage` per-person limit,
++30 a minute and 300 an hour, as on `/api/v1`; past it `429 rate_limited`).
---
ddiff --git a/server/app.js b/server/app.js
iindex 5c9906c..a41a73b 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -89,7 +89,7 @@ function createApp(opts = {}) {
app.use('/api/v1', express.json({ limit: '64kb' }), (req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); }, apiAuth.middleware, v1Router({ domain, apiAuth, limits, hub }));
app.use('/auth', createSessionRoutes(config, userAuth, { fetchImpl }));
app.use(viewerMiddleware(userAuth));
-- app.use(createWebRoutes(config, { domain, sim, log }));
++ app.use(createWebRoutes(config, { domain, sim, limits, log }));
app.use((req, res) => {
if (req.path.startsWith('/api/') || req.path.startsWith('/internal/')) return http.sendProblem(res, 404, 'not_found', { ctx: req.ov });
ddiff --git a/server/realtime.js b/server/realtime.js
iindex e2a5a3c..eee87bd 100644
---- a/server/realtime.js
++++ b/server/realtime.js
@@@ -234,8 +234,13 @@ function createRealtime({ config, keys, userAuth, usage = null, log = console, n
sendToRobotDevices(robotId, { type: 'estop', latched, by, at: iso(now()) });
await refreshConfig(robotId);
}
++ /** 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}`));
++ }
async function bringOnline(conn) {
-- await domain.devices.setOnline(conn.device, true).catch((e) => log.warn(`[Bot] online event: ${e.message}`));
++ await reportOnline(conn, true);
for (const robotId of deviceRobotIds(conn.device)) broadcast(robotId);
}
@@@ -246,7 +251,7 @@ function createRealtime({ config, keys, userAuth, usage = null, log = console, n
if (!conn.online) return;
conn.online = false;
if (silent) return;
-- domain.devices.setOnline(conn.device, false).catch((e) => log.warn(`[Bot] offline event: ${e.message}`));
++ reportOnline(conn, false);
for (const robotId of deviceRobotIds(conn.device)) broadcast(robotId);
}
@@@ -551,7 +556,7 @@ function createRealtime({ config, keys, userAuth, usage = null, log = console, n
}
if (conn.online && now() - conn.lastHeartbeat > offlineAfterMs) {
conn.online = false;
-- domain.devices.setOnline(conn.device, false).catch((e) => log.warn(`[Bot] offline event: ${e.message}`));
++ reportOnline(conn, false);
for (const robotId of deviceRobotIds(conn.device)) broadcast(robotId);
}
}
ddiff --git a/server/sim/index.js b/server/sim/index.js
iindex 27cefaf..71efdf2 100644
---- a/server/sim/index.js
++++ b/server/sim/index.js
@@@ -7,7 +7,7 @@
* `hello`/`config`, sends heartbeats and telemetry, acks each command, mirrors the e-stop and stops by itself
* at a motion command's `deadline_ms` unless a newer command arrived first (the Node's deadman).
*
-- * attach(robotId) start the robot's simulator if its profile is a `sim` one (idempotent) → true/false
++ * attach(robotId) start the robot's simulator if its profile is a `sim` one (idempotent; pass the profile if loaded) → true/false
* startAll() every existing sim robot (boot)
* stop(robotId), stopAll(), running(robotId)
*
@@@ -107,13 +107,19 @@ function createSimulator({ config, domain, hub, now = () => Date.now(), log = co
hub.detachSim(sim.device.id);
}
-- /** Start the robot's simulator when its profile's driver is `sim`; anything else is left alone. */
-- async function attach(robotOrId) {
++ /**
++ * Start the robot's simulator when its profile's driver is `sim`; anything else is left alone. `profile`, when
++ * the caller already loaded it, saves the read.
++ */
++ async function attach(robotOrId, profile) {
const robot = typeof robotOrId === 'string' ? await domain.robots.get(robotOrId) : robotOrId;
if (!robot) return false;
if (sims.has(robot.id)) return true;
-- const row = await getProfile(domain.db, robot.profile_id, robot.profile_version);
-- if (!isSimProfile(row && row.profile)) return false;
++ if (profile === undefined) {
++ const row = await getProfile(domain.db, robot.profile_id, robot.profile_version);
++ profile = row && row.profile;
++ }
++ if (!isSimProfile(profile)) return false;
start(robot);
return true;
}
ddiff --git a/server/web/routes.js b/server/web/routes.js
iindex 91ecd76..b461c27 100644
---- a/server/web/routes.js
++++ b/server/web/routes.js
@@@ -6,13 +6,15 @@
* path and the profile-rendered panel, plan T15 step 3):
*
* GET /robots the signed-in person's robots and the "add a robot" form
-- * POST /robots add one (form post) → 303 to its panel (a `sim` robot) or its pairing page
-- * GET /pair/:id the owner only: a fresh pairing code and the installer command
++ * POST /robots add one (form post) → 303 to its panel (a `sim` robot), else 201 with its pairing page
++ * GET /pair/:id the owner only: a fresh pairing code and the installer command (never minted for a
++ * cross-site navigation or a prefetch: those go to /robots)
* GET /panel/:id a member (or anyone on a `queue` robot): the panel, rendered from the profile
* GET /panel/panel.js, /panel/panel.css the panel client and its sheet (public/, no build step)
*
* Not signed in → 302 to /auth/login?next=<the page>. No access → 403 (bot.not_an_operator / bot.forbidden).
-- * Pages are never cached: a pairing page carries a live code.
++ * Pages are never cached: a pairing page carries a live code. Adding a robot and minting a code count against
++ * the signed-in person's `bot.robot.manage` limit, the same one /api/v1 applies.
*/
const path = require('path');
const express = require('express');
@@@ -24,7 +26,7 @@ const VERSION = require('../../package.json').version;
const PUBLIC = path.join(__dirname, '..', '..', 'public');
const HOLD_RESEND_MS = 150;
--function createWebRoutes(config, { domain = null, sim = null, log = console } = {}) {
++function createWebRoutes(config, { domain = null, sim = null, limits = null, log = console } = {}) {
const r = express.Router();
r.get('/', (req, res) => res.type('text/plain').send(`OpenVibe.Bot ${VERSION} — ok (devices, pairing and control; API under /api/v1)\n`));
r.get('/robots.txt', (req, res) => res.type('text/plain').send('User-agent: *\nDisallow: /\n'));
@@@ -44,7 +46,11 @@ function createWebRoutes(config, { domain = null, sim = null, log = console } =
const row = await getProfile(domain.db, robot.profile_id, robot.profile_version);
return row ? row.profile : null;
};
-- const startSim = (robot) => (sim ? sim.attach(robot).catch((e) => { log.warn(`[Bot] simulator for ${robot.id}: ${e.message}`); return false; }) : Promise.resolve(false));
++ const startSim = (robot, profile) => (sim ? sim.attach(robot, profile).catch((e) => { log.warn(`[Bot] simulator for ${robot.id}: ${e.message}`); return false; }) : Promise.resolve(false));
++ // Counted by who is signed in (req.principal, as /api/v1 counts), or by address before the sign-in redirect.
++ const manage = limits
++ ? [(req, res, next) => { if (req.viewer && !req.principal) req.principal = req.viewer; next(); }, limits('bot.robot.manage', { minute: 30, hour: 300 })]
++ : [];
/** A page handler: 401 → sign in and come back, 403/404 → a plain answer, anything else → the app's 500. */
const page = (fn) => async (req, res, next) => {
res.setHeader('Cache-Control', 'no-store');
@@@ -70,24 +76,33 @@ function createWebRoutes(config, { domain = null, sim = null, log = console } =
r.get('/robots', page(async (req, res) => robotsPage(req, res, requireUser(req))));
-- r.post('/robots', express.urlencoded({ extended: false, limit: '8kb' }), page(async (req, res) => {
++ r.post('/robots', ...manage, express.urlencoded({ extended: false, limit: '8kb' }), page(async (req, res) => {
const me = requireUser(req);
if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');
const body = req.body || {};
const values = { name: typeof body.name === 'string' ? body.name : '', profile_id: String(body.profile_id || ''), access_policy: String(body.access_policy || 'private') };
-- let robot;
++ let robot, pairing;
try {
-- ({ robot } = await domain.robots.create({ owner: me.subject, name: values.name, profile_id: values.profile_id, access_policy: values.access_policy }));
++ ({ robot, pairing } = await domain.robots.create({ owner: me.subject, name: values.name, profile_id: values.profile_id, access_policy: values.access_policy }));
} catch (e) {
if (e.status === 422) { res.status(422); return robotsPage(req, res, me, { error: e.detail || e.code, values }); }
throw e;
}
-- // A simulated robot needs no machine: straight to its panel.
-- return res.redirect(303, (await startSim(robot)) ? `/panel/${robot.id}` : `/pair/${robot.id}`);
++ // A simulated robot needs no machine: straight to its panel. Otherwise the code create minted is shown here.
++ const profile = await profileOf(robot);
++ if (await startSim(robot, profile)) return res.redirect(303, `/panel/${robot.id}`);
++ return res.status(201).type('html').send(renderPairingPage({ robot: domain.present.robot(robot), pairing, profile }));
}));
-- r.get('/pair/:id', page(async (req, res) => {
++ // A load replaces the robot's unused code, so only a navigation from this site (or a typed address) mints one.
++ const crossSiteOrPrefetch = (req) => {
++ const site = req.headers['sec-fetch-site'];
++ 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);
++ };
++ r.get('/pair/:id', ...manage, page(async (req, res) => {
const me = requireUser(req);
++ if (crossSiteOrPrefetch(req)) return res.redirect(303, '/robots');
const robot = await domain.robots.get(req.params.id);
if (!robot) throw new BotError(404, 'bot.robot_not_found', 'no such robot');
if (robot.owner_subject !== me.subject) throw new BotError(403, 'bot.forbidden', 'only the owner may pair a device');
@@@ -105,7 +120,7 @@ function createWebRoutes(config, { domain = null, sim = null, log = console } =
if (!role) throw new BotError(403, 'bot.not_an_operator', 'you have no access to this robot');
const profile = await profileOf(robot);
if (!profile) throw new BotError(404, 'bot.profile_not_found', 'this robot has no profile');
-- await startSim(robot);
++ await startSim(robot, profile);
const { maxCommandMs } = domain.control.effectiveLimits(robot, profile);
res.type('html').send(renderPanel({
robot: domain.present.robot(robot), profile, role,
ddiff --git a/test/panel.test.js b/test/panel.test.js
iindex 77805cd..b06da6a 100644
---- a/test/panel.test.js
++++ b/test/panel.test.js
@@@ -95,6 +95,9 @@ const allowedFor = (profile, role) => [...(DEFAULT_ALLOW[role] || []).filter((k)
assert.ok(html.includes(`data-robot-id="${simId}"`) && html.includes('data-role="owner"') && html.includes('data-estop>'));
const list = await (await get('/robots', alex)).text();
assert.ok(list.includes(`/panel/${simId}`));
++ await t.wait(50);
++ const simEvents = (await t.outboxRows()).filter((e) => e.payload && String(e.payload.device_id || '').startsWith('dev_sim_'));
++ assert.deepStrictEqual(simEvents, [], 'a simulator is reported online in the outbox');
});
await check('a sim robot has telemetry within 1 s and acks a command; the deadman stops it', async () => {
@@@ -142,20 +145,54 @@ const allowedFor = (profile, role) => [...(DEFAULT_ALLOW[role] || []).filter((k)
assert.ok(q.includes('data-role="queue"'));
});
-- await check('POST /robots (a hardware profile) → the owner\'s pairing page; others get 403', async () => {
++ await check('POST /robots (a hardware profile) → 201 with the code create minted; /pair/:id is the owner\'s', async () => {
const r = await post('/robots', alex, { name: 'Arm', profile_id: 'adeept.adr036' });
-- assert.strictEqual(r.status, 303);
-- const loc = r.headers.get('location');
-- assert.match(loc, /^\/pair\/rob_/);
++ assert.strictEqual(r.status, 201);
++ assert.strictEqual(r.headers.get('cache-control'), 'no-store');
++ const created = await r.text();
++ const code = /class="code">([0-9A-Z]{4}-[0-9A-Z]{4})</.exec(created);
++ assert.ok(code, 'no pairing code on the 201 page');
++ assert.match(created, /--driver adeept</);
++ const id = /--robot (rob_[0-9A-Za-z]+)/.exec(created)[1];
++ // The shown code is the robot's only one: it redeems.
++ const paired = await t.call('POST', '/api/v1/pair', { body: { code: code[1], robot: id } });
++ assert.strictEqual(paired.status, 201, paired.text);
++ const loc = `/pair/${id}`;
const page = await get(loc, alex);
assert.strictEqual(page.status, 200);
const html = await page.text();
assert.match(html, /class="code">[0-9A-Z]{4}-[0-9A-Z]{4}</);
-- assert.match(html, /--driver adeept</);
assert.ok(html.includes('data-copy'));
assert.strictEqual((await get(loc, bob)).status, 403);
});
++ await check('/pair/:id mints nothing for a cross-site navigation or a prefetch', async () => {
++ const { robot } = await t.robot(alex, { name: 'Fenced', profile_id: 'adeept.adr036' });
++ const live = async () => (await t.db.many('SELECT id FROM pairing_codes WHERE robot_id = $1 AND used_at IS NULL', [robot.id])).map((x) => x.id);
++ const before = await live();
++ for (const headers of [{ 'Sec-Fetch-Site': 'cross-site' }, { 'Sec-Fetch-Site': 'same-site' }, { 'Sec-Purpose': 'prefetch' }, { Purpose: 'prefetch' }]) {
++ const r = await get(`/pair/${robot.id}`, alex, headers);
++ assert.strictEqual(r.status, 303, JSON.stringify(headers));
++ assert.strictEqual(r.headers.get('location'), '/robots');
++ }
++ assert.deepStrictEqual(await live(), before, 'a code was replaced');
++ assert.strictEqual((await get(`/pair/${robot.id}`, alex, { 'Sec-Fetch-Site': 'same-origin' })).status, 200);
++ assert.notDeepStrictEqual(await live(), before);
++ });
++
++ await check('the form and /pair/:id count against the person\'s bot.robot.manage limit', async () => {
++ const dave = t.network.newUser('dave');
++ const { robot } = await t.robot(dave, { name: 'Limited', profile_id: 'adeept.adr036' });
++ const statuses = [];
++ for (let i = 0; i < 31; i++) statuses.push((await get(`/pair/${robot.id}`, dave)).status);
++ assert.strictEqual(statuses[0], 200);
++ assert.strictEqual(statuses[statuses.length - 1], 429, statuses.join(','));
++ assert.strictEqual((await post('/robots', dave, { name: 'More', profile_id: 'sim.rover' })).status, 429);
++ // Another person is counted apart.
++ assert.strictEqual((await get('/robots', alex)).status, 200);
++ assert.notStrictEqual((await post('/robots', alex, { name: 'Mine', profile_id: 'adeept.adr036' })).status, 429);
++ });
++
await check('a bad form answers 422 with the form; a cross-site post is refused', async () => {
const bad = await post('/robots', alex, { name: '', profile_id: 'sim.rover' });
assert.strictEqual(bad.status, 422);