openvibers/openvibe.bot Commits
Limit web robot and pairing writes per person and stop stray code mints
Changes
@@@ -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).@@@ -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`).---@@@ -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 });@@@ -234,8 +234,13 @@ function createRealtime({ config, keys, userAuth, usage = null, log = console, nsendToRobotDevices(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, nif (!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);}}@@@ -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 = cohub.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;}@@@ -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,@@@ -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);
@@ -12,6 +12,7 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato
@@ -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.
- 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.
- 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 `/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.
- 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).
- 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).
@@ -403,7 +403,10 @@ routes. Both come before any lookup, so a real robot or device id answers exactl
@@ -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) |
| `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
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`).
---
---
@@ -89,7 +89,7 @@ function createApp(opts = {}) {
@@ -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('/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('/auth', createSessionRoutes(config, userAuth, { fetchImpl }));
app.use(viewerMiddleware(userAuth));
app.use(viewerMiddleware(userAuth));
- app.use(createWebRoutes(config, { domain, sim, log }));
+ app.use(createWebRoutes(config, { domain, sim, limits, log }));
app.use((req, res) => {
app.use((req, res) => {
if (req.path.startsWith('/api/') || req.path.startsWith('/internal/')) return http.sendProblem(res, 404, 'not_found', { ctx: req.ov });
if (req.path.startsWith('/api/') || req.path.startsWith('/internal/')) return http.sendProblem(res, 404, 'not_found', { ctx: req.ov });
@@ -234,8 +234,13 @@ function createRealtime({ config, keys, userAuth, usage = null, log = console, n
@@ -234,8 +234,13 @@ function createRealtime({ config, keys, userAuth, usage = null, log = console, n
sendToRobotDevices(robotId, { type: 'estop', latched, by, at: iso(now()) });
sendToRobotDevices(robotId, { type: 'estop', latched, by, at: iso(now()) });
await refreshConfig(robotId);
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) {
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);
for (const robotId of deviceRobotIds(conn.device)) broadcast(robotId);
}
}
@@ -246,7 +251,7 @@ function createRealtime({ config, keys, userAuth, usage = null, log = console, n
@@ -246,7 +251,7 @@ function createRealtime({ config, keys, userAuth, usage = null, log = console, n
if (!conn.online) return;
if (!conn.online) return;
conn.online = false;
conn.online = false;
if (silent) return;
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);
for (const robotId of deviceRobotIds(conn.device)) broadcast(robotId);
}
}
@@ -551,7 +556,7 @@ function createRealtime({ config, keys, userAuth, usage = null, log = console, n
@@ -551,7 +556,7 @@ function createRealtime({ config, keys, userAuth, usage = null, log = console, n
}
}
if (conn.online && now() - conn.lastHeartbeat > offlineAfterMs) {
if (conn.online && now() - conn.lastHeartbeat > offlineAfterMs) {
conn.online = false;
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);
for (const robotId of deviceRobotIds(conn.device)) broadcast(robotId);
}
}
}
}
@@ -7,7 +7,7 @@
@@ -7,7 +7,7 @@
* `hello`/`config`, sends heartbeats and telemetry, acks each command, mirrors the e-stop and stops by itself
* `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).
* 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)
* startAll() every existing sim robot (boot)
* stop(robotId), stopAll(), running(robotId)
* stop(robotId), stopAll(), running(robotId)
*
*
@@ -107,13 +107,19 @@ function createSimulator({ config, domain, hub, now = () => Date.now(), log = co
@@ -107,13 +107,19 @@ function createSimulator({ config, domain, hub, now = () => Date.now(), log = co
hub.detachSim(sim.device.id);
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;
const robot = typeof robotOrId === 'string' ? await domain.robots.get(robotOrId) : robotOrId;
if (!robot) return false;
if (!robot) return false;
if (sims.has(robot.id)) return true;
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);
start(robot);
return true;
return true;
}
}
@@ -6,13 +6,15 @@
@@ -6,13 +6,15 @@
* path and the profile-rendered panel, plan T15 step 3):
* path and the profile-rendered panel, plan T15 step 3):
*
*
* GET /robots the signed-in person's robots and the "add a robot" form
* 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/: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)
* 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).
* 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 path = require('path');
const express = require('express');
const express = require('express');
@@ -24,7 +26,7 @@ const VERSION = require('../../package.json').version;
@@ -24,7 +26,7 @@ const VERSION = require('../../package.json').version;
const PUBLIC = path.join(__dirname, '..', '..', 'public');
const PUBLIC = path.join(__dirname, '..', '..', 'public');
const HOLD_RESEND_MS = 150;
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();
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('/', (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'));
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 } =
@@ -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);
const row = await getProfile(domain.db, robot.profile_id, robot.profile_version);
return row ? row.profile : null;
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. */
/** 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) => {
const page = (fn) => async (req, res, next) => {
res.setHeader('Cache-Control', 'no-store');
res.setHeader('Cache-Control', 'no-store');
@@ -70,24 +76,33 @@ function createWebRoutes(config, { domain = null, sim = null, log = console } =
@@ -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.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);
const me = requireUser(req);
if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');
if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');
const body = req.body || {};
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') };
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 {
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) {
} catch (e) {
if (e.status === 422) { res.status(422); return robotsPage(req, res, me, { error: e.detail || e.code, values }); }
if (e.status === 422) { res.status(422); return robotsPage(req, res, me, { error: e.detail || e.code, values }); }
throw e;
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);
const me = requireUser(req);
+ if (crossSiteOrPrefetch(req)) return res.redirect(303, '/robots');
const robot = await domain.robots.get(req.params.id);
const robot = await domain.robots.get(req.params.id);
if (!robot) throw new BotError(404, 'bot.robot_not_found', 'no such robot');
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');
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 } =
@@ -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');
if (!role) throw new BotError(403, 'bot.not_an_operator', 'you have no access to this robot');
const profile = await profileOf(robot);
const profile = await profileOf(robot);
if (!profile) throw new BotError(404, 'bot.profile_not_found', 'this robot has no profile');
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);
const { maxCommandMs } = domain.control.effectiveLimits(robot, profile);
res.type('html').send(renderPanel({
res.type('html').send(renderPanel({
robot: domain.present.robot(robot), profile, role,
robot: domain.present.robot(robot), profile, role,
@@ -95,6 +95,9 @@ const allowedFor = (profile, role) => [...(DEFAULT_ALLOW[role] || []).filter((k)
@@ -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>'));
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();
const list = await (await get('/robots', alex)).text();
assert.ok(list.includes(`/panel/${simId}`));
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 () => {
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)
@@ -142,20 +145,54 @@ const allowedFor = (profile, role) => [...(DEFAULT_ALLOW[role] || []).filter((k)
assert.ok(q.includes('data-role="queue"'));
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' });
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);
const page = await get(loc, alex);
assert.strictEqual(page.status, 200);
assert.strictEqual(page.status, 200);
const html = await page.text();
const html = await page.text();
assert.match(html, /class="code">[0-9A-Z]{4}-[0-9A-Z]{4}</);
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.ok(html.includes('data-copy'));
assert.strictEqual((await get(loc, bob)).status, 403);
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 () => {
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' });
const bad = await post('/robots', alex, { name: '', profile_id: 'sim.rover' });
assert.strictEqual(bad.status, 422);
assert.strictEqual(bad.status, 422);