openvibers/openvibe.bot Commits

Send whipurl only when BOTWHIPBASE is set, trimming its base (#5)

OP OpenVibers committed 2 Oct 2026 · e389bdd29b · 8 files, +93 −19

local checks ✓ 4/4 · no CI checks · review SHIP by DeepSeek harness

https://github.com/OpenVibers/OpenVibe.Bot/pull/5

Changes

ddiff --git a/.env.example b/.env.example
iindex 045285e..fd1bf55 100644
---- a/.env.example
++++ b/.env.example
@@@ -59,5 +59,5 @@ OV_OAUTH_CLIENT_SECRET=
#BOT_JOBS=on
#BOT_JOBS_INTERVAL_MS=5000
#BOT_INSTALLER_URL=https://openvibe.bot/install
--# Video: devices publish to OpenRe WHIP at <base>/<publish key>; empty = pair without video
--#BOT_WHIP_BASE=https://ingest.openre.stream/whip
++# 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
ddiff --git a/README.md b/README.md
iindex 8b106c8..c8fd622 100644
---- a/README.md
++++ b/README.md
@@@ -8,6 +8,8 @@ Production runs the Node process under systemd as `openvibe-bot.service` (unit r
To run the schema migrations before the service serves, start it once with the direct owner connection in `/etc/openvibe/bot.env` (`DATABASE_DIRECT_URL`); the boot applies `migrations/` and then serves on the pooled `DATABASE_URL`.
++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.
## Deploy files
ddiff --git a/docs/protocol.md b/docs/protocol.md
iindex e2f3628..c68c20f 100644
---- a/docs/protocol.md
++++ b/docs/protocol.md
@@@ -73,11 +73,11 @@ with; `allowed_commands` is what the owner may send: the profile's kinds, cut by
always with `halt`. Both are re-sent to the connected device the moment the owner changes the limits.
`whip_url` is where the device publishes its camera: OpenRe's WHIP ingest (`POST`, RFC 9725, body
--`application/sdp`) at `<BOT_WHIP_BASE>/<publish_key>`, the base defaulting to
--`https://ingest.openre.stream/whip`. It is built from the device's own publish key (never the owner's
--stream key), so it is a secret like the key: returned only by `pair` / `POST /pair` and by a rotation
--(which issues a new key, so a new URL), never by a read. With `BOT_WHIP_BASE` empty it is `null` and the
--device runs without video.
++`application/sdp`) at `<BOT_WHIP_BASE>/<publish_key>`, any trailing slash on the base trimmed. It is built
++from the device's own publish key (never the owner's stream key), so it is a secret like the key: returned
++only by `pair` / `POST /pair` and by a rotation (which issues a new key, so a new URL), never by a read.
++With `BOT_WHIP_BASE` unset or empty the field is left out of the answer entirely and the device runs
++without video.
### 1.1 Command values (the profile's `commands`)
ddiff --git a/server/api/v1.js b/server/api/v1.js
iindex 359a194..024cf01 100644
---- a/server/api/v1.js
++++ b/server/api/v1.js
@@@ -149,7 +149,7 @@ function v1Router({ domain, apiAuth, limits, hub }) {
if (!d) fail(404, 'bot.device_not_found', 'no such device');
await owner(req, json(d.robot_ids, [])[0]);
const rotated = await domain.devices.rotate(req.params.id);
-- res.json({ device: domain.present.device(rotated.device), credential: rotated.credential, publish_key: rotated.publish_key, whip_url: rotated.whip_url });
++ res.json({ device: domain.present.device(rotated.device), credential: rotated.credential, publish_key: rotated.publish_key, ...(rotated.whip_url ? { whip_url: rotated.whip_url } : {}) });
}));
r.post('/devices/:id/revoke', manage, wrap(async (req, res) => {
const d = await domain.devices.get(req.params.id);
@@@ -196,7 +196,7 @@ function v1Router({ domain, apiAuth, limits, hub }) {
device_kind: req.body?.device_kind || 'onboard', drivers: Array.isArray(req.body?.drivers) ? req.body.drivers : [],
capabilities: req.body?.capabilities && typeof req.body.capabilities === 'object' ? req.body.capabilities : {}, name: req.body?.name || null,
});
-- res.status(201).json({ device_id: result.device.id, credential: result.credential, publish_key: result.publish_key, whip_url: result.whip_url, robot_id: json(result.device.robot_ids, [])[0], profile: result.profile });
++ res.status(201).json({ device_id: result.device.id, credential: result.credential, publish_key: result.publish_key, ...(result.whip_url ? { whip_url: result.whip_url } : {}), robot_id: json(result.device.robot_ids, [])[0], profile: result.profile });
}));
return r;
ddiff --git a/server/config.js b/server/config.js
iindex 37cf299..c97a81e 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -98,10 +98,11 @@ function loadConfig(env = process.env) {
enabled: env.BOT_JOBS !== 'off',
intervalMs: int(env.BOT_JOBS_INTERVAL_MS, 5000),
},
-- // Video: a device publishes its camera to OpenRe's WHIP ingest at <whipBase>/<its publish key>
-- // (`whip_url` in the pairing and rotation answers). Set it empty to pair devices without video.
++ // Video: a device publishes its camera to the WHIP ingest at <whipBase>/<its publish key>, returned
++ // as `whip_url` by the pairing and rotation answers only. Unset or empty (the default) means no
++ // whip_url at all, so devices pair without video until the operator names an ingest base.
media: {
-- whipBase: trim(env.BOT_WHIP_BASE != null ? env.BOT_WHIP_BASE : 'https://ingest.openre.stream/whip'),
++ whipBase: trim(env.BOT_WHIP_BASE || ''),
},
// The one-line installer the owner copies next to the pairing code (the agent job builds it).
installer: {
ddiff --git a/server/domain/index.js b/server/domain/index.js
iindex 40d43a0..397c137 100644
---- a/server/domain/index.js
++++ b/server/domain/index.js
@@@ -214,15 +214,19 @@ function createDomain({ db, config, outbox, link = null, now = () => Date.now(),
});
if (result.error) fail(result.error[0], result.error[1], result.error[2]);
const profile = await getProfile(db, (await getRobot(result.robot_id)).profile_id);
-- return { device: result.device, credential: result.credential, publish_key: result.publishKey, whip_url: whipUrl(result.publishKey), profile: profile ? profile.profile : null };
++ const whUrl = whipUrl(result.publishKey);
++ return { device: result.device, credential: result.credential, publish_key: result.publishKey, ...(whUrl ? { whip_url: whUrl } : {}), profile: profile ? profile.profile : null };
}
/**
* Where the device publishes its camera: OpenRe's WHIP ingest (`POST <base>/<key>`, RFC 9725) with
* this device's publish key as the stream key. It carries the key, so it is shown once, with the key.
++ * The base's trailing slashes are trimmed; with no base (BOT_WHIP_BASE unset or empty) it is null, and
++ * the caller omits the field entirely rather than sending null or an empty string.
*/
function whipUrl(publishKey) {
const base = config.media && config.media.whipBase;
-- return base && publishKey ? `${base}/${encodeURIComponent(publishKey)}` : null;
++ const clean = base ? String(base).replace(/\/+$/, '') : '';
++ return clean && publishKey ? `${clean}/${encodeURIComponent(publishKey)}` : null;
}
async function prunePairingCodes() {
await db.query('DELETE FROM pairing_codes WHERE expires_at < $1', [iso(now() - 24 * 3600 * 1000)]);
@@@ -250,7 +254,8 @@ function createDomain({ db, config, outbox, link = null, now = () => Date.now(),
`UPDATE devices SET credential_hash = $2, credential_prev_hash = $3, prev_valid_until = $4, publish_key_hash = $5, updated_at = $6
WHERE id = $1 RETURNING *`,
[id, hashSecret(credential), d.credential_hash, iso(now() + config.device.rotateGraceMs), hashSecret(publishKey), at]);
-- return { device: updated, credential, publish_key: publishKey, whip_url: whipUrl(publishKey) };
++ const whUrl = whipUrl(publishKey);
++ return { device: updated, credential, publish_key: publishKey, ...(whUrl ? { whip_url: whUrl } : {}) };
}
async function revokeDevice(id) {
const d = await getDevice(id);
ddiff --git a/server/realtime.js b/server/realtime.js
iindex 83920b8..c855c88 100644
---- a/server/realtime.js
++++ b/server/realtime.js
@@@ -200,7 +200,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
});
attachDevice(conn, r.device);
sendFrame(conn, 'paired', {
-- device_id: r.device.id, credential: r.credential, publish_key: r.publish_key, whip_url: r.whip_url,
++ device_id: r.device.id, credential: r.credential, publish_key: r.publish_key, ...(r.whip_url ? { whip_url: r.whip_url } : {}),
robot_ids: deviceRobotIds(r.device), profile_id: r.profile ? r.profile.id : null, profile: r.profile,
});
sendHello(conn);
ddiff --git a/test/pairing.test.js b/test/pairing.test.js
iindex d7c63fc..8f1ff50 100644
---- a/test/pairing.test.js
++++ b/test/pairing.test.js
@@@ -4,8 +4,10 @@
const assert = require('assert');
const { boot, check, done } = require('./helpers/app');
++const WHIP_BASE = 'https://whip.test/ingest';
++
(async () => {
-- const t = await boot();
++ const t = await boot({ env: { BOT_WHIP_BASE: WHIP_BASE } });
const alex = t.network.newUser('alex');
const redeem = (body) => t.call('POST', '/api/v1/pair', { token: null, body });
@@@ -18,7 +20,7 @@ const { boot, check, done } = require('./helpers/app');
assert.ok(first.json.credential && first.json.credential.length >= 40);
assert.ok(first.json.publish_key && first.json.publish_key.length >= 40);
assert.strictEqual(first.json.profile.id, 'sim.rover');
-- assert.strictEqual(first.json.whip_url, `https://ingest.openre.stream/whip/${first.json.publish_key}`, 'video bootstraps from the publish key');
++ assert.strictEqual(first.json.whip_url, `${WHIP_BASE}/${first.json.publish_key}`, 'video bootstraps from the publish key');
const again = await redeem({ robot: robot.id, code: pairing.code });
assert.strictEqual(again.status, 403);
assert.strictEqual(again.json.code, 'bot.pairing_code_used');
@@@ -72,7 +74,9 @@ const { boot, check, done } = require('./helpers/app');
const rot = await t.call('POST', `/api/v1/devices/${deviceId}/rotate`, { user: alex });
assert.strictEqual(rot.status, 200, rot.text);
assert.ok(await t.domain.devices.byCredential(rot.json.credential), 'the new credential authenticates');
-- assert.strictEqual(rot.json.whip_url, `https://ingest.openre.stream/whip/${rot.json.publish_key}`, 'the new publish key brings its WHIP URL');
++ assert.strictEqual(rot.json.whip_url, `${WHIP_BASE}/${rot.json.publish_key}`, 'the new publish key brings its WHIP URL');
++ assert.ok(!rot.text.includes(paired.json.publish_key), 'the old publish key is not returned');
++ assert.ok(!rot.text.includes(paired.json.whip_url), 'the old WHIP URL is no longer returned');
assert.ok(await t.domain.devices.byCredential(paired.json.credential), 'the old credential still works inside the 60 s grace');
t.clock.offset += 61 * 1000;
assert.strictEqual(await t.domain.devices.byCredential(paired.json.credential), null, 'the old credential is dead after 60 s');
@@@ -85,6 +89,68 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual(await t.domain.devices.byCredential(rot.json.credential), null, 'the credential is dead');
});
++ await check('a trailing slash on BOT_WHIP_BASE is trimmed for the pair answer and the paired frame', async () => {
++ const t2 = await boot({ env: { BOT_WHIP_BASE: `${WHIP_BASE}/` } });
++ try {
++ const alex2 = t2.network.newUser('alex');
++ const { robot, pairing } = await t2.robot(alex2);
++ const http = await t2.call('POST', '/api/v1/pair', { token: null, body: { robot: robot.id, code: pairing.code } });
++ assert.strictEqual(http.status, 201, http.text);
++ assert.strictEqual(http.json.whip_url, `${WHIP_BASE}/${http.json.publish_key}`, 'POST /pair trims the base');
++ const second = await t2.robot(alex2);
++ const dev = await t2.ws('/device');
++ dev.send({ type: 'pair', robot: second.robot.id, code: second.pairing.code });
++ const paired = await dev.waitFor((m) => m.type === 'paired');
++ assert.strictEqual(paired.whip_url, `${WHIP_BASE}/${paired.publish_key}`, 'the paired frame trims the base');
++ dev.close();
++ } finally { await t2.close(); }
++ });
++
++ await check('with BOT_WHIP_BASE unset the pair answer and the paired frame have no whip_url key', async () => {
++ const t3 = await boot();
++ try {
++ const alex3 = t3.network.newUser('alex');
++ const { robot, pairing } = await t3.robot(alex3);
++ const http = await t3.call('POST', '/api/v1/pair', { token: null, body: { robot: robot.id, code: pairing.code } });
++ assert.strictEqual(http.status, 201, http.text);
++ assert.ok(!('whip_url' in http.json), `POST /pair carried a whip_url: ${http.text}`);
++ assert.ok(!http.text.includes('"whip_url"'), 'the pair answer has no whip_url field');
++ const second = await t3.robot(alex3);
++ const dev = await t3.ws('/device');
++ dev.send({ type: 'pair', robot: second.robot.id, code: second.pairing.code });
++ const paired = await dev.waitFor((m) => m.type === 'paired');
++ assert.ok(!('whip_url' in paired), 'the paired frame carried a whip_url');
++ assert.ok(!JSON.stringify(paired).includes('"whip_url"'), 'the paired frame has no whip_url field');
++ dev.close();
++ } finally { await t3.close(); }
++ });
++
++ await check('reads and robot_state carry neither the publish key nor a WHIP URL', async () => {
++ const { robot, pairing } = await t.robot(alex);
++ const dev = await t.ws('/device');
++ dev.send({ type: 'pair', robot: robot.id, code: pairing.code });
++ const paired = await dev.waitFor((m) => m.type === 'paired');
++ assert.strictEqual(paired.whip_url, `${WHIP_BASE}/${paired.publish_key}`, 'a set base reaches the device once');
++ const paths = ['/api/v1/robots', `/api/v1/robots/${robot.id}`, `/api/v1/robots/${robot.id}/devices`,
++ `/api/v1/robots/${robot.id}/operators`, `/api/v1/robots/${robot.id}/audit`];
++ for (const p of paths) {
++ const r = await t.call('GET', p, { user: alex });
++ assert.ok(!r.text.includes(paired.publish_key), `${p} leaked the publish key`);
++ assert.ok(!r.text.includes('whip'), `${p} mentioned a WHIP URL`);
++ }
++ const op = await t.ws('/control', { headers: { Authorization: `Bearer ${t.network.signUser(alex)}` } });
++ op.send({ type: 'join', robot_id: robot.id });
++ await op.waitFor((m) => m.type === 'joined');
++ dev.send({ type: 'status', firmware: 'test' });
++ const frame = await op.waitFor((m) => m.type === 'robot_state' && m.state && m.state.online);
++ assert.ok(frame, 'a robot_state frame arrived');
++ const serialized = JSON.stringify(frame);
++ assert.ok(!serialized.includes(paired.publish_key), 'robot_state leaked the publish key');
++ assert.ok(!serialized.includes('whip'), 'robot_state mentioned a WHIP URL');
++ op.close();
++ dev.close();
++ });
++
await t.close();
done();
})();