openvibers/openvibe.bot Commits
Serve GET /install as a redirect and add --driver from the robot profile
Changes
@@@ -62,5 +62,8 @@ OV_OAUTH_CLIENT_SECRET=#BOT_JOBS=on#BOT_JOBS_INTERVAL_MS=5000#BOT_INSTALLER_URL=https://openvibe.bot/install++# Where GET /install redirects (302): OpenVibe.Node's installer script. https on raw.githubusercontent.com,++# github.com, objects.githubusercontent.com or openvibe.bot only; anything else refuses to boot.++#BOT_INSTALLER_SOURCE_URL=https://raw.githubusercontent.com/OpenVibers/OpenVibe.Node/main/install/install.sh# The WHIP ingest base each device publishes to; whip_url = <base>/<publish key>. Unset or empty: pair without video.#BOT_WHIP_BASE=https://example.invalid/whip@@@ -12,7 +12,7 @@ Set `BOT_PAIRING_AUTHORITY` to `bot` (the default) or `network`; any other valueSet `BOT_WHIP_BASE` in `/etc/openvibe/bot.env` to the WHIP ingest base each device publishes to (`whip_url = <base>/<publish key>`, sent once with the pairing); leave it unset to pair devices without video.--`/install` is proxied to the app; the installer script itself ships with the T15 device-agent job.++`/install` is proxied to the app, which answers a 302 to OpenVibe.Node's installer script (`BOT_INSTALLER_SOURCE_URL`, by default `install/install.sh` on OpenVibe.Node's `main`; https on an allow-listed GitHub or openvibe.bot host, checked at boot, and no query parameter changes the target). The pairing's installer command adds `--driver adeept|adeept-mecanum|cozmo` for those profiles and nothing for the others (the dry-run `none`).## Deploy files@@@ -32,11 +32,16 @@ so a reconnecting agent cannot be shadowed by a stale socket.**Pairing authority** (`BOT_PAIRING_AUTHORITY`, `bot` by default, or `network`). With `network` thepairing code is minted by OpenVibe.Network (Bot calls `POST /internal/node-pairings` with its own servicetoken, `{owner: {kind: 'user', subject}, ref: <robot id>}`) and Bot stores none; the installer command--carries `--network <Network URL> --pairing pair_… --code XXXX-XXXX`, and the machine pairs on Network and++carries `--network <Network URL> --pairing pair_… --code XXXX-XXXX` (plus `--driver <kind>`, as below), and the machine pairs on Network andconnects with a node token (below). The `pair` frame is then answered `error` `bot.pairing_moved` (its`detail` names the Network URL) and the socket stays unpaired. Devices that already hold a Bot credentialkeep connecting with it either way.++**The installer command** is `curl -fsSL <BOT_INSTALLER_URL> | sh -s -- --robot rob_… --code XXXX-XXXX` (or the++Network form above), plus `--driver adeept`, `adeept-mecanum` or `cozmo` for the `adeept.adr036`,++`adeept.adr036.mecanum` and `cozmo` profiles; any other profile gets no `--driver` (the installer's dry-run++`none`). `GET /install` answers a 302 to `BOT_INSTALLER_SOURCE_URL` (OpenVibe.Node's `install/install.sh`).++**A Network-paired machine** (a node principal `nod_…`, paired on OpenVibe.Network for one of itsowner's robots) carries `Authorization: Bearer <node token>` instead: a Network JWT with `actor_type``node`, `sub` `node:nod_…` and audience `openvibe.bot`. Bot binds it to a device on first use (from@@@ -80,7 +80,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());++ app.use(createWebRoutes(config));app.use((req, res) => {if (req.path.startsWith('/api/') || req.path.startsWith('/internal/')) return http.sendProblem(res, 404, 'not_found', { ctx: req.ov });@@@ -15,6 +15,18 @@ const bool = (v, d = false) => (v == null || v === '' ? d : ['1', 'true', 'yes',const trim = (u) => String(u || '').replace(/\/+$/, '');const list = (v) => String(v || '').split(',').map((s) => s.trim()).filter(Boolean);const PAIRING_AUTHORITIES = ['bot', 'network'];++// Where GET /install may send the client: OpenVibe.Node's canonical script lives on GitHub (raw, a release++// asset) or on openvibe.bot itself. Anything else refuses to boot.++const INSTALLER_SOURCE_HOSTS = ['raw.githubusercontent.com', 'github.com', 'objects.githubusercontent.com', 'openvibe.bot'];++++/** BOT_INSTALLER_SOURCE_URL must be https on an allow-listed host, so /install can never become an open redirect. */++function checkInstallerSource(sourceUrl) {++ let u = null;++ try { u = new URL(sourceUrl); } catch { /* reported below */ }++ if (!u || u.protocol !== 'https:' || !INSTALLER_SOURCE_HOSTS.includes(u.hostname)) {++ throw new Error(`BOT_INSTALLER_SOURCE_URL must be an https URL on ${INSTALLER_SOURCE_HOSTS.join(', ')}, not ${JSON.stringify(sourceUrl)}`);++ }++}function loadConfig(env = process.env) {const nodeEnv = env.NODE_ENV || 'development';@@@ -26,6 +38,8 @@ function loadConfig(env = process.env) {if (!PAIRING_AUTHORITIES.includes(pairingAuthority)) {throw new Error(`BOT_PAIRING_AUTHORITY must be ${PAIRING_AUTHORITIES.join(' or ')}, not ${JSON.stringify(pairingAuthority)}`);}++ const installerSource = String(env.BOT_INSTALLER_SOURCE_URL || 'https://raw.githubusercontent.com/OpenVibers/OpenVibe.Node/main/install/install.sh').trim();++ checkInstallerSource(installerSource);return {nodeEnv,isProduction,@@@ -115,9 +129,14 @@ function loadConfig(env = process.env) {media: {whipBase: trim(env.BOT_WHIP_BASE || ''),},-- // The one-line installer the owner copies next to the pairing code (the agent job builds it).++ // The one-line installer the owner copies next to the pairing code. scriptUrl is what the command++ // prints; sourceUrl is where GET /install sends the client (302). Bot keeps no copy of the script:++ // it redirects to OpenVibe.Node's canonical one, and only this config (never a query parameter)++ // chooses the target.installer: {scriptUrl: trim(env.BOT_INSTALLER_URL || 'https://openvibe.bot/install'),++ sourceUrl: installerSource,++ sourceHosts: INSTALLER_SOURCE_HOSTS,},};}@@@ -48,6 +48,11 @@ function newCode() {const formatCode = (s) => `${s.slice(0, 4)}-${s.slice(4)}`;const isCodeShape = (s) => /^[0-9A-HJKMNP-TV-Z]{8}$/.test(s);++// The installer driver a robot's profile needs (OpenVibe.Node install/install.sh --driver). Simulated,++// camera and unknown profiles get `none`, the dry-run plugin, which is also the installer's default.++const DRIVER_BY_PROFILE = Object.freeze({ 'adeept.adr036': 'adeept', 'adeept.adr036.mecanum': 'adeept-mecanum', cozmo: 'cozmo' });++const driverForProfile = (profileId) => (Object.hasOwn(DRIVER_BY_PROFILE, profileId) ? DRIVER_BY_PROFILE[profileId] : 'none');++const clampNum = (v, lo, hi, d = 0) => { const n = Number(v); return Number.isFinite(n) ? Math.min(hi, Math.max(lo, n)) : d; };// Scientific pitch: 'A4' is 440 Hz; a sharp (#) or flat (b) after the letter.@@@ -116,7 +121,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, now = ()const id = prefixedId('rob', now());const at = iso(now());// A Network-minted code comes first, so a Network that does not answer leaves no robot behind.-- const minted = networkPairing() ? await mintOnNetwork(id, owner, installerUrl) : null;++ const minted = networkPairing() ? await mintOnNetwork(id, owner, installerUrl, driverForProfile(profile.id)) : null;const created = await db.tx(async (t) => {await t.query(`INSERT INTO robots (id, owner_subject, name, profile_id, profile_version, access_policy, limits, created_at, updated_at)VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $8)`,@@@ -124,7 +129,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, now = ()await t.query(`INSERT INTO robot_operators (robot_id, subject, role, added_by, created_at) VALUES ($1, $2, 'owner', $2, $3)`, [id, owner, at]);return t.maybe('SELECT * FROM robots WHERE id = $1', [id]);});-- const pairing = minted || await createPairingCode(id, owner, installerUrl);++ const pairing = minted || await createPairingCode(id, owner, installerUrl, created);return { robot: created, pairing };}function cleanLimits(limits = {}) {@@@ -158,8 +163,10 @@ function createDomain({ db, config, outbox, link = null, nodes = null, now = ()// ── Pairing (ADR-043 decision 2) ──────────────────────────────────────────────────────────────const networkPairing = () => config.pairing.authority === 'network';-- async function createPairingCode(robotId, createdBy, installerUrl) {-- if (networkPairing()) return mintOnNetwork(robotId, (await getRobot(robotId)).owner_subject, installerUrl);++ async function createPairingCode(robotId, createdBy, installerUrl, robotRow = null) {++ const robot = robotRow || await getRobot(robotId);++ const driver = driverForProfile(robot && robot.profile_id);++ if (networkPairing()) return mintOnNetwork(robotId, robot.owner_subject, installerUrl, driver);const code = newCode();const at = iso(now());const expires = iso(now() + config.pairing.ttlMs);@@@ -169,25 +176,27 @@ function createDomain({ db, config, outbox, link = null, nodes = null, now = ()await t.query('INSERT INTO pairing_codes (id, robot_id, code_hash, created_by, expires_at, created_at) VALUES ($1, $2, $3, $4, $5, $6)',[prefixedId('pair', now()), robotId, hashSecret(code), createdBy || null, expires, at]);});-- return { code: formatCode(code), expires_at: expires, installer: installerCommand(robotId, formatCode(code), installerUrl) };++ return { code: formatCode(code), expires_at: expires, installer: installerCommand(robotId, formatCode(code), installerUrl, driver) };}-- function installerCommand(robotId, code, installerUrl) {++ /** ` --driver <kind>` for a profile that needs a real driver; nothing for `none` (the installer's default). */++ const driverFlag = (driver) => (driver && driver !== 'none' ? ` --driver ${driver}` : '');++ function installerCommand(robotId, code, installerUrl, driver = 'none') {const url = installerUrl || config.installer.scriptUrl;-- return `curl -fsSL ${url} | sh -s -- --robot ${robotId} --code ${code}`;++ return `curl -fsSL ${url} | sh -s -- --robot ${robotId} --code ${code}${driverFlag(driver)}`;}/*** BOT_PAIRING_AUTHORITY=network (plan T15 B2): Network mints the code for the robot's owner (POST* /internal/node-pairings, ref = the robot) and Bot stores none. The machine redeems it on Network and* reaches Bot with a node token (bindNode). A Network that does not answer is 503 bot.network_unavailable.*/-- async function mintOnNetwork(robotId, ownerSubject, installerUrl) {++ async function mintOnNetwork(robotId, ownerSubject, installerUrl, driver = 'none') {if (!nodes) fail(503, 'bot.network_unavailable', 'Network pairing is not configured here');const p = await nodes.pair({ subject: ownerSubject, ref: robotId });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}`,++ installer: `curl -fsSL ${url} | sh -s -- --network ${config.network.url} --pairing ${p.pairing_id} --code ${p.code}${driverFlag(driver)}`,};}/**@@@ -672,7 +681,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, now = ()present: { robot: presentRobot, device: presentDevice },robots: { create: createRobot, list: listRobots, get: getRobot, update: updateRobot, remove: removeRobot },members: { roleOf, add: addOperator, remove: removeOperator, list: listOperators },-- pairing: { create: createPairingCode, redeem, prune: prunePairingCodes, installerCommand, whipUrl },++ pairing: { create: createPairingCode, redeem, prune: prunePairingCodes, installerCommand, driverForProfile, whipUrl },devices: { byCredential, bindNode, issuePublishKey, updateDeclared, revokeNode, get: getDevice, listForRobot: listDevicesForRobot, rotate: rotateDevice, revoke: revokeDevice, touchSeen, setOnline },estop: { set: setEstop, clear: clearEstop },queue: { join: joinQueue, state: queueState, currentTurn, consume: consumeTurn, sweep: sweepQueues },@@@ -681,4 +690,4 @@ function createDomain({ db, config, outbox, link = null, nodes = null, now = ()};}--module.exports = { createDomain, DEFAULT_ALLOW, KINDS, normaliseCode, formatCode, newCode, isCodeShape, noteToHz };++module.exports = { createDomain, DEFAULT_ALLOW, DRIVER_BY_PROFILE, driverForProfile, KINDS, normaliseCode, formatCode, newCode, isCodeShape, noteToHz };@@@ -2,15 +2,18 @@/*** The non-API surface: a one-line text placeholder at `/` for the health of the process (the panel and-- * the site are designed separately), and nothing else. No pages, no CSS, no copy beyond this.++ * the site are designed separately), and `/install`, a 302 to OpenVibe.Node's installer script (the++ * one-paste command, ADR-043). No pages, no CSS, no copy beyond this.*/const express = require('express');const VERSION = require('../../package.json').version;--function createWebRoutes() {++function createWebRoutes(config) {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'));++ // `curl -fsSL` follows the redirect. The target is config only (checked at boot): no query parameter steers it.++ r.get('/install', (req, res) => res.redirect(302, config.installer.sourceUrl));return r;}nnew file mode 100644@@@ -0,0 +1,67 @@++'use strict';++// GET /install (plan T15 step 1): a 302 to OpenVibe.Node's installer script, chosen by config only and checked at++// boot (https, allow-listed host). The pairing's installer command carries `--driver <kind>` from the profile,++// and nothing for a profile whose driver is `none`.++const assert = require('assert');++const { boot, check, done } = require('./helpers/app');++const { loadConfig } = require('../server/config');++const { driverForProfile } = require('../server/domain');++++(async () => {++ const NODE_INSTALLER = 'https://raw.githubusercontent.com/OpenVibers/OpenVibe.Node/main/install/install.sh';++++ await check('BOT_INSTALLER_SOURCE_URL: Node\'s script by default, https on an allow-listed host or no boot', async () => {++ assert.strictEqual(loadConfig({}).installer.sourceUrl, NODE_INSTALLER);++ const release = 'https://github.com/OpenVibers/OpenVibe.Node/releases/latest/download/install.sh';++ assert.strictEqual(loadConfig({ BOT_INSTALLER_SOURCE_URL: release }).installer.sourceUrl, release);++ for (const bad of ['http://raw.githubusercontent.com/OpenVibers/OpenVibe.Node/main/install/install.sh', 'https://evil.test/install.sh',++ 'https://raw.githubusercontent.com.evil.test/x.sh', 'javascript:alert(1)', 'not a url']) {++ assert.throws(() => loadConfig({ BOT_INSTALLER_SOURCE_URL: bad }), /BOT_INSTALLER_SOURCE_URL must be an https URL on raw\.githubusercontent\.com/, bad);++ }++ });++++ await check('driverForProfile: adeept, adeept-mecanum, cozmo, else none', async () => {++ assert.strictEqual(driverForProfile('adeept.adr036'), 'adeept');++ assert.strictEqual(driverForProfile('adeept.adr036.mecanum'), 'adeept-mecanum');++ assert.strictEqual(driverForProfile('cozmo'), 'cozmo');++ for (const id of ['sim.rover', 'camera.onvif', 'toString', undefined]) assert.strictEqual(driverForProfile(id), 'none', String(id));++ });++++ const source = 'https://github.com/OpenVibers/OpenVibe.Node/releases/download/v1.2.3/install.sh';++ const t = await boot({ env: { BOT_INSTALLER_SOURCE_URL: source } });++ const alex = t.network.newUser('alex');++++ await check('GET /install → 302 to the configured source; no query parameter changes the target', async () => {++ for (const q of ['', '?url=https://evil.test/x.sh', '?to=https://evil.test/x.sh', '?url=//evil.test&to=/x&next=https://evil.test']) {++ const res = await fetch(`${t.base}/install${q}`, { redirect: 'manual' });++ assert.strictEqual(res.status, 302, q);++ assert.strictEqual(res.headers.get('location'), source, q);++ }++ });++++ await check('the installer command ends with --driver for an adeept, mecanum or cozmo robot, and has none for sim.rover', async () => {++ for (const [profile_id, driver] of [['adeept.adr036', 'adeept'], ['adeept.adr036.mecanum', 'adeept-mecanum'], ['cozmo', 'cozmo']]) {++ const { robot, pairing } = await t.robot(alex, { profile_id });++ assert.strictEqual(pairing.installer, `curl -fsSL https://openvibe.bot/install | sh -s -- --robot ${robot.id} --code ${pairing.code} --driver ${driver}`);++ const again = await t.call('POST', `/api/v1/robots/${robot.id}/pairing-code`, { user: alex });++ assert.strictEqual(again.status, 201, again.text);++ assert.match(again.json.installer, new RegExp(`--robot ${robot.id} --code ${again.json.code} --driver ${driver}$`));++ }++ const { robot, pairing } = await t.robot(alex, { profile_id: 'sim.rover' });++ assert.strictEqual(pairing.installer, `curl -fsSL https://openvibe.bot/install | sh -s -- --robot ${robot.id} --code ${pairing.code}`);++ });++ await t.close();++++ const n = await boot({ env: { BOT_PAIRING_AUTHORITY: 'network' } });++ await check('network authority: the Network form gains --driver too, on create and on a new code', async () => {++ const sam = n.network.newUser('sam');++ const { robot, pairing } = await n.robot(sam, { profile_id: 'cozmo' });++ const asked = n.network.pairings.at(-1);++ assert.strictEqual(pairing.installer, `curl -fsSL https://openvibe.bot/install | sh -s -- --network ${n.network.url} --pairing ${asked.pairing_id} --code ${asked.code} --driver cozmo`);++ const again = await n.call('POST', `/api/v1/robots/${robot.id}/pairing-code`, { user: sam });++ assert.strictEqual(again.status, 201, again.text);++ assert.match(again.json.installer, / --code \S+ --driver cozmo$/);++ });++ await n.close();++ done();++})();
@@ -62,5 +62,8 @@ OV_OAUTH_CLIENT_SECRET=
@@ -62,5 +62,8 @@ OV_OAUTH_CLIENT_SECRET=
#BOT_JOBS=on
#BOT_JOBS=on
#BOT_JOBS_INTERVAL_MS=5000
#BOT_JOBS_INTERVAL_MS=5000
#BOT_INSTALLER_URL=https://openvibe.bot/install
#BOT_INSTALLER_URL=https://openvibe.bot/install
+# Where GET /install redirects (302): OpenVibe.Node's installer script. https on raw.githubusercontent.com,
+# github.com, objects.githubusercontent.com or openvibe.bot only; anything else refuses to boot.
+#BOT_INSTALLER_SOURCE_URL=https://raw.githubusercontent.com/OpenVibers/OpenVibe.Node/main/install/install.sh
# The WHIP ingest base each device publishes to; whip_url = <base>/<publish key>. Unset or empty: pair without video.
# The WHIP ingest base each device publishes to; whip_url = <base>/<publish key>. Unset or empty: pair without video.
#BOT_WHIP_BASE=https://example.invalid/whip
#BOT_WHIP_BASE=https://example.invalid/whip
@@ -12,7 +12,7 @@ Set `BOT_PAIRING_AUTHORITY` to `bot` (the default) or `network`; any other value
@@ -12,7 +12,7 @@ Set `BOT_PAIRING_AUTHORITY` to `bot` (the default) or `network`; any other value
Set `BOT_WHIP_BASE` in `/etc/openvibe/bot.env` to the WHIP ingest base each device publishes to (`whip_url = <base>/<publish key>`, sent once with the pairing); leave it unset to pair devices without video.
Set `BOT_WHIP_BASE` in `/etc/openvibe/bot.env` to the WHIP ingest base each device publishes to (`whip_url = <base>/<publish key>`, sent once with the pairing); leave it unset to pair devices without video.
-`/install` is proxied to the app; the installer script itself ships with the T15 device-agent job.
+`/install` is proxied to the app, which answers a 302 to OpenVibe.Node's installer script (`BOT_INSTALLER_SOURCE_URL`, by default `install/install.sh` on OpenVibe.Node's `main`; https on an allow-listed GitHub or openvibe.bot host, checked at boot, and no query parameter changes the target). The pairing's installer command adds `--driver adeept|adeept-mecanum|cozmo` for those profiles and nothing for the others (the dry-run `none`).
## Deploy files
## Deploy files
@@ -32,11 +32,16 @@ so a reconnecting agent cannot be shadowed by a stale socket.
@@ -32,11 +32,16 @@ so a reconnecting agent cannot be shadowed by a stale socket.
**Pairing authority** (`BOT_PAIRING_AUTHORITY`, `bot` by default, or `network`). With `network` the
**Pairing authority** (`BOT_PAIRING_AUTHORITY`, `bot` by default, or `network`). With `network` the
pairing code is minted by OpenVibe.Network (Bot calls `POST /internal/node-pairings` with its own service
pairing code is minted by OpenVibe.Network (Bot calls `POST /internal/node-pairings` with its own service
token, `{owner: {kind: 'user', subject}, ref: <robot id>}`) and Bot stores none; the installer command
token, `{owner: {kind: 'user', subject}, ref: <robot id>}`) and Bot stores none; the installer command
-carries `--network <Network URL> --pairing pair_… --code XXXX-XXXX`, and the machine pairs on Network and
+carries `--network <Network URL> --pairing pair_… --code XXXX-XXXX` (plus `--driver <kind>`, as below), and the machine pairs on Network and
connects with a node token (below). The `pair` frame is then answered `error` `bot.pairing_moved` (its
connects with a node token (below). The `pair` frame is then answered `error` `bot.pairing_moved` (its
`detail` names the Network URL) and the socket stays unpaired. Devices that already hold a Bot credential
`detail` names the Network URL) and the socket stays unpaired. Devices that already hold a Bot credential
keep connecting with it either way.
keep connecting with it either way.
+**The installer command** is `curl -fsSL <BOT_INSTALLER_URL> | sh -s -- --robot rob_… --code XXXX-XXXX` (or the
+Network form above), plus `--driver adeept`, `adeept-mecanum` or `cozmo` for the `adeept.adr036`,
+`adeept.adr036.mecanum` and `cozmo` profiles; any other profile gets no `--driver` (the installer's dry-run
+`none`). `GET /install` answers a 302 to `BOT_INSTALLER_SOURCE_URL` (OpenVibe.Node's `install/install.sh`).
+
**A Network-paired machine** (a node principal `nod_…`, paired on OpenVibe.Network for one of its
**A Network-paired machine** (a node principal `nod_…`, paired on OpenVibe.Network for one of its
owner's robots) carries `Authorization: Bearer <node token>` instead: a Network JWT with `actor_type`
owner's robots) carries `Authorization: Bearer <node token>` instead: a Network JWT with `actor_type`
`node`, `sub` `node:nod_…` and audience `openvibe.bot`. Bot binds it to a device on first use (from
`node`, `sub` `node:nod_…` and audience `openvibe.bot`. Bot binds it to a device on first use (from
@@ -80,7 +80,7 @@ function createApp(opts = {}) {
@@ -80,7 +80,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());
+ app.use(createWebRoutes(config));
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 });
@@ -15,6 +15,18 @@ const bool = (v, d = false) => (v == null || v === '' ? d : ['1', 'true', 'yes',
@@ -15,6 +15,18 @@ const bool = (v, d = false) => (v == null || v === '' ? d : ['1', 'true', 'yes',
const trim = (u) => String(u || '').replace(/\/+$/, '');
const trim = (u) => String(u || '').replace(/\/+$/, '');
const list = (v) => String(v || '').split(',').map((s) => s.trim()).filter(Boolean);
const list = (v) => String(v || '').split(',').map((s) => s.trim()).filter(Boolean);
const PAIRING_AUTHORITIES = ['bot', 'network'];
const PAIRING_AUTHORITIES = ['bot', 'network'];
+// Where GET /install may send the client: OpenVibe.Node's canonical script lives on GitHub (raw, a release
+// asset) or on openvibe.bot itself. Anything else refuses to boot.
+const INSTALLER_SOURCE_HOSTS = ['raw.githubusercontent.com', 'github.com', 'objects.githubusercontent.com', 'openvibe.bot'];
+
+/** BOT_INSTALLER_SOURCE_URL must be https on an allow-listed host, so /install can never become an open redirect. */
+function checkInstallerSource(sourceUrl) {
+ let u = null;
+ try { u = new URL(sourceUrl); } catch { /* reported below */ }
+ if (!u || u.protocol !== 'https:' || !INSTALLER_SOURCE_HOSTS.includes(u.hostname)) {
+ throw new Error(`BOT_INSTALLER_SOURCE_URL must be an https URL on ${INSTALLER_SOURCE_HOSTS.join(', ')}, not ${JSON.stringify(sourceUrl)}`);
+ }
+}
function loadConfig(env = process.env) {
function loadConfig(env = process.env) {
const nodeEnv = env.NODE_ENV || 'development';
const nodeEnv = env.NODE_ENV || 'development';
@@ -26,6 +38,8 @@ function loadConfig(env = process.env) {
@@ -26,6 +38,8 @@ function loadConfig(env = process.env) {
if (!PAIRING_AUTHORITIES.includes(pairingAuthority)) {
if (!PAIRING_AUTHORITIES.includes(pairingAuthority)) {
throw new Error(`BOT_PAIRING_AUTHORITY must be ${PAIRING_AUTHORITIES.join(' or ')}, not ${JSON.stringify(pairingAuthority)}`);
throw new Error(`BOT_PAIRING_AUTHORITY must be ${PAIRING_AUTHORITIES.join(' or ')}, not ${JSON.stringify(pairingAuthority)}`);
}
}
+ const installerSource = String(env.BOT_INSTALLER_SOURCE_URL || 'https://raw.githubusercontent.com/OpenVibers/OpenVibe.Node/main/install/install.sh').trim();
+ checkInstallerSource(installerSource);
return {
return {
nodeEnv,
nodeEnv,
isProduction,
isProduction,
@@ -115,9 +129,14 @@ function loadConfig(env = process.env) {
@@ -115,9 +129,14 @@ function loadConfig(env = process.env) {
media: {
media: {
whipBase: trim(env.BOT_WHIP_BASE || ''),
whipBase: trim(env.BOT_WHIP_BASE || ''),
},
},
- // The one-line installer the owner copies next to the pairing code (the agent job builds it).
+ // The one-line installer the owner copies next to the pairing code. scriptUrl is what the command
+ // prints; sourceUrl is where GET /install sends the client (302). Bot keeps no copy of the script:
+ // it redirects to OpenVibe.Node's canonical one, and only this config (never a query parameter)
+ // chooses the target.
installer: {
installer: {
scriptUrl: trim(env.BOT_INSTALLER_URL || 'https://openvibe.bot/install'),
scriptUrl: trim(env.BOT_INSTALLER_URL || 'https://openvibe.bot/install'),
+ sourceUrl: installerSource,
+ sourceHosts: INSTALLER_SOURCE_HOSTS,
},
},
};
};
}
}
@@ -48,6 +48,11 @@ function newCode() {
@@ -48,6 +48,11 @@ function newCode() {
const formatCode = (s) => `${s.slice(0, 4)}-${s.slice(4)}`;
const formatCode = (s) => `${s.slice(0, 4)}-${s.slice(4)}`;
const isCodeShape = (s) => /^[0-9A-HJKMNP-TV-Z]{8}$/.test(s);
const isCodeShape = (s) => /^[0-9A-HJKMNP-TV-Z]{8}$/.test(s);
+// The installer driver a robot's profile needs (OpenVibe.Node install/install.sh --driver). Simulated,
+// camera and unknown profiles get `none`, the dry-run plugin, which is also the installer's default.
+const DRIVER_BY_PROFILE = Object.freeze({ 'adeept.adr036': 'adeept', 'adeept.adr036.mecanum': 'adeept-mecanum', cozmo: 'cozmo' });
+const driverForProfile = (profileId) => (Object.hasOwn(DRIVER_BY_PROFILE, profileId) ? DRIVER_BY_PROFILE[profileId] : 'none');
+
const clampNum = (v, lo, hi, d = 0) => { const n = Number(v); return Number.isFinite(n) ? Math.min(hi, Math.max(lo, n)) : d; };
const clampNum = (v, lo, hi, d = 0) => { const n = Number(v); return Number.isFinite(n) ? Math.min(hi, Math.max(lo, n)) : d; };
// Scientific pitch: 'A4' is 440 Hz; a sharp (#) or flat (b) after the letter.
// Scientific pitch: 'A4' is 440 Hz; a sharp (#) or flat (b) after the letter.
@@ -116,7 +121,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, now = ()
@@ -116,7 +121,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, now = ()
const id = prefixedId('rob', now());
const id = prefixedId('rob', now());
const at = iso(now());
const at = iso(now());
// A Network-minted code comes first, so a Network that does not answer leaves no robot behind.
// A Network-minted code comes first, so a Network that does not answer leaves no robot behind.
- const minted = networkPairing() ? await mintOnNetwork(id, owner, installerUrl) : null;
+ const minted = networkPairing() ? await mintOnNetwork(id, owner, installerUrl, driverForProfile(profile.id)) : null;
const created = await db.tx(async (t) => {
const created = await db.tx(async (t) => {
await t.query(`INSERT INTO robots (id, owner_subject, name, profile_id, profile_version, access_policy, limits, created_at, updated_at)
await t.query(`INSERT INTO robots (id, owner_subject, name, profile_id, profile_version, access_policy, limits, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $8)`,
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $8)`,
@@ -124,7 +129,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, now = ()
@@ -124,7 +129,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, now = ()
await t.query(`INSERT INTO robot_operators (robot_id, subject, role, added_by, created_at) VALUES ($1, $2, 'owner', $2, $3)`, [id, owner, at]);
await t.query(`INSERT INTO robot_operators (robot_id, subject, role, added_by, created_at) VALUES ($1, $2, 'owner', $2, $3)`, [id, owner, at]);
return t.maybe('SELECT * FROM robots WHERE id = $1', [id]);
return t.maybe('SELECT * FROM robots WHERE id = $1', [id]);
});
});
- const pairing = minted || await createPairingCode(id, owner, installerUrl);
+ const pairing = minted || await createPairingCode(id, owner, installerUrl, created);
return { robot: created, pairing };
return { robot: created, pairing };
}
}
function cleanLimits(limits = {}) {
function cleanLimits(limits = {}) {
@@ -158,8 +163,10 @@ function createDomain({ db, config, outbox, link = null, nodes = null, now = ()
@@ -158,8 +163,10 @@ function createDomain({ db, config, outbox, link = null, nodes = null, now = ()
// ── Pairing (ADR-043 decision 2) ──────────────────────────────────────────────────────────────
// ── Pairing (ADR-043 decision 2) ──────────────────────────────────────────────────────────────
const networkPairing = () => config.pairing.authority === 'network';
const networkPairing = () => config.pairing.authority === 'network';
- async function createPairingCode(robotId, createdBy, installerUrl) {
- if (networkPairing()) return mintOnNetwork(robotId, (await getRobot(robotId)).owner_subject, installerUrl);
+ async function createPairingCode(robotId, createdBy, installerUrl, robotRow = null) {
+ const robot = robotRow || await getRobot(robotId);
+ const driver = driverForProfile(robot && robot.profile_id);
+ if (networkPairing()) return mintOnNetwork(robotId, robot.owner_subject, installerUrl, driver);
const code = newCode();
const code = newCode();
const at = iso(now());
const at = iso(now());
const expires = iso(now() + config.pairing.ttlMs);
const expires = iso(now() + config.pairing.ttlMs);
@@ -169,25 +176,27 @@ function createDomain({ db, config, outbox, link = null, nodes = null, now = ()
@@ -169,25 +176,27 @@ function createDomain({ db, config, outbox, link = null, nodes = null, now = ()
await t.query('INSERT INTO pairing_codes (id, robot_id, code_hash, created_by, expires_at, created_at) VALUES ($1, $2, $3, $4, $5, $6)',
await t.query('INSERT INTO pairing_codes (id, robot_id, code_hash, created_by, expires_at, created_at) VALUES ($1, $2, $3, $4, $5, $6)',
[prefixedId('pair', now()), robotId, hashSecret(code), createdBy || null, expires, at]);
[prefixedId('pair', now()), robotId, hashSecret(code), createdBy || null, expires, at]);
});
});
- return { code: formatCode(code), expires_at: expires, installer: installerCommand(robotId, formatCode(code), installerUrl) };
+ return { code: formatCode(code), expires_at: expires, installer: installerCommand(robotId, formatCode(code), installerUrl, driver) };
}
}
- function installerCommand(robotId, code, installerUrl) {
+ /** ` --driver <kind>` for a profile that needs a real driver; nothing for `none` (the installer's default). */
+ const driverFlag = (driver) => (driver && driver !== 'none' ? ` --driver ${driver}` : '');
+ function installerCommand(robotId, code, installerUrl, driver = 'none') {
const url = installerUrl || config.installer.scriptUrl;
const url = installerUrl || config.installer.scriptUrl;
- return `curl -fsSL ${url} | sh -s -- --robot ${robotId} --code ${code}`;
+ return `curl -fsSL ${url} | sh -s -- --robot ${robotId} --code ${code}${driverFlag(driver)}`;
}
}
/**
/**
* BOT_PAIRING_AUTHORITY=network (plan T15 B2): Network mints the code for the robot's owner (POST
* BOT_PAIRING_AUTHORITY=network (plan T15 B2): Network mints the code for the robot's owner (POST
* /internal/node-pairings, ref = the robot) and Bot stores none. The machine redeems it on Network and
* /internal/node-pairings, ref = the robot) and Bot stores none. The machine redeems it on Network and
* reaches Bot with a node token (bindNode). A Network that does not answer is 503 bot.network_unavailable.
* reaches Bot with a node token (bindNode). A Network that does not answer is 503 bot.network_unavailable.
*/
*/
- async function mintOnNetwork(robotId, ownerSubject, installerUrl) {
+ async function mintOnNetwork(robotId, ownerSubject, installerUrl, driver = 'none') {
if (!nodes) fail(503, 'bot.network_unavailable', 'Network pairing is not configured here');
if (!nodes) fail(503, 'bot.network_unavailable', 'Network pairing is not configured here');
const p = await nodes.pair({ subject: ownerSubject, ref: robotId });
const p = await nodes.pair({ subject: ownerSubject, ref: robotId });
if (!p || typeof p.pairing_id !== 'string' || typeof p.code !== 'string') fail(503, 'bot.network_unavailable', 'Network answered no pairing code');
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;
const url = installerUrl || config.installer.scriptUrl;
return {
return {
code: p.code, expires_at: p.expires_at, pairing_id: p.pairing_id,
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}`,
+ installer: `curl -fsSL ${url} | sh -s -- --network ${config.network.url} --pairing ${p.pairing_id} --code ${p.code}${driverFlag(driver)}`,
};
};
}
}
/**
/**
@@ -672,7 +681,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, now = ()
@@ -672,7 +681,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, now = ()
present: { robot: presentRobot, device: presentDevice },
present: { robot: presentRobot, device: presentDevice },
robots: { create: createRobot, list: listRobots, get: getRobot, update: updateRobot, remove: removeRobot },
robots: { create: createRobot, list: listRobots, get: getRobot, update: updateRobot, remove: removeRobot },
members: { roleOf, add: addOperator, remove: removeOperator, list: listOperators },
members: { roleOf, add: addOperator, remove: removeOperator, list: listOperators },
- pairing: { create: createPairingCode, redeem, prune: prunePairingCodes, installerCommand, whipUrl },
+ pairing: { create: createPairingCode, redeem, prune: prunePairingCodes, installerCommand, driverForProfile, whipUrl },
devices: { byCredential, bindNode, issuePublishKey, updateDeclared, revokeNode, get: getDevice, listForRobot: listDevicesForRobot, rotate: rotateDevice, revoke: revokeDevice, touchSeen, setOnline },
devices: { byCredential, bindNode, issuePublishKey, updateDeclared, revokeNode, get: getDevice, listForRobot: listDevicesForRobot, rotate: rotateDevice, revoke: revokeDevice, touchSeen, setOnline },
estop: { set: setEstop, clear: clearEstop },
estop: { set: setEstop, clear: clearEstop },
queue: { join: joinQueue, state: queueState, currentTurn, consume: consumeTurn, sweep: sweepQueues },
queue: { join: joinQueue, state: queueState, currentTurn, consume: consumeTurn, sweep: sweepQueues },
@@ -681,4 +690,4 @@ function createDomain({ db, config, outbox, link = null, nodes = null, now = ()
@@ -681,4 +690,4 @@ function createDomain({ db, config, outbox, link = null, nodes = null, now = ()
};
};
}
}
-module.exports = { createDomain, DEFAULT_ALLOW, KINDS, normaliseCode, formatCode, newCode, isCodeShape, noteToHz };
+module.exports = { createDomain, DEFAULT_ALLOW, DRIVER_BY_PROFILE, driverForProfile, KINDS, normaliseCode, formatCode, newCode, isCodeShape, noteToHz };
@@ -2,15 +2,18 @@
@@ -2,15 +2,18 @@
/**
/**
* The non-API surface: a one-line text placeholder at `/` for the health of the process (the panel and
* The non-API surface: a one-line text placeholder at `/` for the health of the process (the panel and
- * the site are designed separately), and nothing else. No pages, no CSS, no copy beyond this.
+ * the site are designed separately), and `/install`, a 302 to OpenVibe.Node's installer script (the
+ * one-paste command, ADR-043). No pages, no CSS, no copy beyond this.
*/
*/
const express = require('express');
const express = require('express');
const VERSION = require('../../package.json').version;
const VERSION = require('../../package.json').version;
-function createWebRoutes() {
+function createWebRoutes(config) {
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'));
+ // `curl -fsSL` follows the redirect. The target is config only (checked at boot): no query parameter steers it.
+ r.get('/install', (req, res) => res.redirect(302, config.installer.sourceUrl));
return r;
return r;
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,67 @@
@@ -0,0 +1,67 @@
+'use strict';
+// GET /install (plan T15 step 1): a 302 to OpenVibe.Node's installer script, chosen by config only and checked at
+// boot (https, allow-listed host). The pairing's installer command carries `--driver <kind>` from the profile,
+// and nothing for a profile whose driver is `none`.
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/app');
+const { loadConfig } = require('../server/config');
+const { driverForProfile } = require('../server/domain');
+
+(async () => {
+ const NODE_INSTALLER = 'https://raw.githubusercontent.com/OpenVibers/OpenVibe.Node/main/install/install.sh';
+
+ await check('BOT_INSTALLER_SOURCE_URL: Node\'s script by default, https on an allow-listed host or no boot', async () => {
+ assert.strictEqual(loadConfig({}).installer.sourceUrl, NODE_INSTALLER);
+ const release = 'https://github.com/OpenVibers/OpenVibe.Node/releases/latest/download/install.sh';
+ assert.strictEqual(loadConfig({ BOT_INSTALLER_SOURCE_URL: release }).installer.sourceUrl, release);
+ for (const bad of ['http://raw.githubusercontent.com/OpenVibers/OpenVibe.Node/main/install/install.sh', 'https://evil.test/install.sh',
+ 'https://raw.githubusercontent.com.evil.test/x.sh', 'javascript:alert(1)', 'not a url']) {
+ assert.throws(() => loadConfig({ BOT_INSTALLER_SOURCE_URL: bad }), /BOT_INSTALLER_SOURCE_URL must be an https URL on raw\.githubusercontent\.com/, bad);
+ }
+ });
+
+ await check('driverForProfile: adeept, adeept-mecanum, cozmo, else none', async () => {
+ assert.strictEqual(driverForProfile('adeept.adr036'), 'adeept');
+ assert.strictEqual(driverForProfile('adeept.adr036.mecanum'), 'adeept-mecanum');
+ assert.strictEqual(driverForProfile('cozmo'), 'cozmo');
+ for (const id of ['sim.rover', 'camera.onvif', 'toString', undefined]) assert.strictEqual(driverForProfile(id), 'none', String(id));
+ });
+
+ const source = 'https://github.com/OpenVibers/OpenVibe.Node/releases/download/v1.2.3/install.sh';
+ const t = await boot({ env: { BOT_INSTALLER_SOURCE_URL: source } });
+ const alex = t.network.newUser('alex');
+
+ await check('GET /install → 302 to the configured source; no query parameter changes the target', async () => {
+ for (const q of ['', '?url=https://evil.test/x.sh', '?to=https://evil.test/x.sh', '?url=//evil.test&to=/x&next=https://evil.test']) {
+ const res = await fetch(`${t.base}/install${q}`, { redirect: 'manual' });
+ assert.strictEqual(res.status, 302, q);
+ assert.strictEqual(res.headers.get('location'), source, q);
+ }
+ });
+
+ await check('the installer command ends with --driver for an adeept, mecanum or cozmo robot, and has none for sim.rover', async () => {
+ for (const [profile_id, driver] of [['adeept.adr036', 'adeept'], ['adeept.adr036.mecanum', 'adeept-mecanum'], ['cozmo', 'cozmo']]) {
+ const { robot, pairing } = await t.robot(alex, { profile_id });
+ assert.strictEqual(pairing.installer, `curl -fsSL https://openvibe.bot/install | sh -s -- --robot ${robot.id} --code ${pairing.code} --driver ${driver}`);
+ const again = await t.call('POST', `/api/v1/robots/${robot.id}/pairing-code`, { user: alex });
+ assert.strictEqual(again.status, 201, again.text);
+ assert.match(again.json.installer, new RegExp(`--robot ${robot.id} --code ${again.json.code} --driver ${driver}$`));
+ }
+ const { robot, pairing } = await t.robot(alex, { profile_id: 'sim.rover' });
+ assert.strictEqual(pairing.installer, `curl -fsSL https://openvibe.bot/install | sh -s -- --robot ${robot.id} --code ${pairing.code}`);
+ });
+ await t.close();
+
+ const n = await boot({ env: { BOT_PAIRING_AUTHORITY: 'network' } });
+ await check('network authority: the Network form gains --driver too, on create and on a new code', async () => {
+ const sam = n.network.newUser('sam');
+ const { robot, pairing } = await n.robot(sam, { profile_id: 'cozmo' });
+ const asked = n.network.pairings.at(-1);
+ assert.strictEqual(pairing.installer, `curl -fsSL https://openvibe.bot/install | sh -s -- --network ${n.network.url} --pairing ${asked.pairing_id} --code ${asked.code} --driver cozmo`);
+ const again = await n.call('POST', `/api/v1/robots/${robot.id}/pairing-code`, { user: sam });
+ assert.strictEqual(again.status, 201, again.text);
+ assert.match(again.json.installer, / --code \S+ --driver cozmo$/);
+ });
+ await n.close();
+ done();
+})();