openvibers/openvibe.bot Commits
Send whipurl only when BOTWHIPBASE is set, trimming its base (#5)
Changes
@@@ -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@@@ -8,6 +8,8 @@ Production runs the Node process under systemd as `openvibe-bot.service` (unit rTo 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@@@ -73,11 +73,11 @@ with; `allowed_commands` is what the owner may send: the profile's kinds, cut byalways 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`)@@@ -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;@@@ -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: {@@@ -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 = $6WHERE 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);@@@ -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);@@@ -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();})();
@@ -59,5 +59,5 @@ OV_OAUTH_CLIENT_SECRET=
@@ -59,5 +59,5 @@ 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
-# 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
@@ -8,6 +8,8 @@ Production runs the Node process under systemd as `openvibe-bot.service` (unit r
@@ -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`.
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.
`/install` is proxied to the app; the installer script itself ships with the T15 device-agent job.
## Deploy files
## Deploy files
@@ -73,11 +73,11 @@ with; `allowed_commands` is what the owner may send: the profile's kinds, cut by
@@ -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.
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
`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`)
### 1.1 Command values (the profile's `commands`)
@@ -149,7 +149,7 @@ function v1Router({ domain, apiAuth, limits, hub }) {
@@ -149,7 +149,7 @@ function v1Router({ domain, apiAuth, limits, hub }) {
if (!d) fail(404, 'bot.device_not_found', 'no such device');
if (!d) fail(404, 'bot.device_not_found', 'no such device');
await owner(req, json(d.robot_ids, [])[0]);
await owner(req, json(d.robot_ids, [])[0]);
const rotated = await domain.devices.rotate(req.params.id);
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) => {
r.post('/devices/:id/revoke', manage, wrap(async (req, res) => {
const d = await domain.devices.get(req.params.id);
const d = await domain.devices.get(req.params.id);
@@ -196,7 +196,7 @@ function v1Router({ domain, apiAuth, limits, hub }) {
@@ -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 : [],
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,
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;
return r;
@@ -98,10 +98,11 @@ function loadConfig(env = process.env) {
@@ -98,10 +98,11 @@ function loadConfig(env = process.env) {
enabled: env.BOT_JOBS !== 'off',
enabled: env.BOT_JOBS !== 'off',
intervalMs: int(env.BOT_JOBS_INTERVAL_MS, 5000),
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: {
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).
// The one-line installer the owner copies next to the pairing code (the agent job builds it).
installer: {
installer: {
@@ -214,15 +214,19 @@ function createDomain({ db, config, outbox, link = null, now = () => Date.now(),
@@ -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]);
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);
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
* 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.
* 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) {
function whipUrl(publishKey) {
const base = config.media && config.media.whipBase;
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() {
async function prunePairingCodes() {
await db.query('DELETE FROM pairing_codes WHERE expires_at < $1', [iso(now() - 24 * 3600 * 1000)]);
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(),
@@ -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
`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 *`,
WHERE id = $1 RETURNING *`,
[id, hashSecret(credential), d.credential_hash, iso(now() + config.device.rotateGraceMs), hashSecret(publishKey), at]);
[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) {
async function revokeDevice(id) {
const d = await getDevice(id);
const d = await getDevice(id);
@@ -200,7 +200,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -200,7 +200,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
});
});
attachDevice(conn, r.device);
attachDevice(conn, r.device);
sendFrame(conn, 'paired', {
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,
robot_ids: deviceRobotIds(r.device), profile_id: r.profile ? r.profile.id : null, profile: r.profile,
});
});
sendHello(conn);
sendHello(conn);
@@ -4,8 +4,10 @@
@@ -4,8 +4,10 @@
const assert = require('assert');
const assert = require('assert');
const { boot, check, done } = require('./helpers/app');
const { boot, check, done } = require('./helpers/app');
+const WHIP_BASE = 'https://whip.test/ingest';
+
(async () => {
(async () => {
- const t = await boot();
+ const t = await boot({ env: { BOT_WHIP_BASE: WHIP_BASE } });
const alex = t.network.newUser('alex');
const alex = t.network.newUser('alex');
const redeem = (body) => t.call('POST', '/api/v1/pair', { token: null, body });
const redeem = (body) => t.call('POST', '/api/v1/pair', { token: null, body });
@@ -18,7 +20,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -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.credential && first.json.credential.length >= 40);
assert.ok(first.json.publish_key && first.json.publish_key.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.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 });
const again = await redeem({ robot: robot.id, code: pairing.code });
assert.strictEqual(again.status, 403);
assert.strictEqual(again.status, 403);
assert.strictEqual(again.json.code, 'bot.pairing_code_used');
assert.strictEqual(again.json.code, 'bot.pairing_code_used');
@@ -72,7 +74,9 @@ const { boot, check, done } = require('./helpers/app');
@@ -72,7 +74,9 @@ const { boot, check, done } = require('./helpers/app');
const rot = await t.call('POST', `/api/v1/devices/${deviceId}/rotate`, { user: alex });
const rot = await t.call('POST', `/api/v1/devices/${deviceId}/rotate`, { user: alex });
assert.strictEqual(rot.status, 200, rot.text);
assert.strictEqual(rot.status, 200, rot.text);
assert.ok(await t.domain.devices.byCredential(rot.json.credential), 'the new credential authenticates');
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');
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;
t.clock.offset += 61 * 1000;
assert.strictEqual(await t.domain.devices.byCredential(paired.json.credential), null, 'the old credential is dead after 60 s');
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');
@@ -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');
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();
await t.close();
done();
done();
})();
})();