openvibers/openvibe.bot Commits

Bot: the panel plays the robot's live camera from its OpenRe WebRTC session (owner, operators, public embed); copy and video sizing updated. (#38)

OP OpenVibers committed 7 Oct 2026 · bddc363e00 · 13 files, +386 −39

**Changes**

- `server/openre/client.js:126` — added `sessions(streamId, owner, { state='open' })` (`GET /api/v1/sessions?stream_id=…&state=open`) and `playback(id, owner)` (`GET /api/v1/sessions/:id/playback`, `null` on 404); no new scope needed (`openre.session.read` was already requested).
- `server/domain/index.js:411` — `liveVideo(robotId)`: with the robot's stored stream id, reads the open session (as `X-OV-Subject` = owner), passes over anything that is not `protocol: 'webrtc'`, reads its playback descriptor, returns `webrtc.signaling_url` (validated `ws(s)://`) or `null`; exported at `:910` as `domain.video.live`.
- `server/web/render.js:143` — `cameraTiles(w, profile, signaling_url)` emits `data-signaling-url` (HTML-escaped, like every value) on the `<figure class="camera">`; threaded through `renderWidget` (`:160`) and `renderPanel` (`:299`); when absent the tile is byte-for-byte the old placeholder.
- `server/web/routes.js:46-52` — `PANEL_CSP` and `embedCsp(config)`: `default-src 'self'` plus exactly `script-src 'self' https://esm.sh` and `connect-src 'self' wss://ingest.openre.stream`, keeping `frame-ancestors`/`object-src`/`base-uri`; the site-wide CSP in `server/app.js` is untouched. `sendPanel` (`:260`) runs the session lookup beside the owner's streaming read (both OpenRe) under `Promise.all`, catches OpenRe failures to a placeholder, and gates the URL: a `watcher` (the anonymous embed) only when `robot.embed_public`, every member role as the route already decides; `/panel/:id` now serves `PANEL_CSP` (`:293`), `/panel/:id/embed` uses `embedCsp` (`:308`).
- `public/panel.js:437-566` — for each `[data-signaling-url]` tile: creates `<video muted autoplay playsinline>` inside `[data-camera-screen]` (server-side HTML still has no `<video>`), runs `watch → sfu-viewer-ready → sfu-viewer-create-transport → sfu-viewer-connect-transport → sfu-viewer-consume` against the URL with mediasoup-client, attaches the consumed tracks via `MediaStream`, and reconnects with the `/control` backoff (`min(10s, 500·2^n)`); the placeholder is hidden only while a track plays, and returns on close/error. No CSS, markup copy or class changes (only the `hidden` attribute), no build step.
- `README.md:17` — the tile "plays the robot's OpenRe WebRTC session when one is live… and stays the placeholder when there is none"; the "placeholder until" sentence is gone. `README.md:85` — `openre.session.read` is now the panel's live video rather than "for later".
- `test/helpers/stubs.js:188,204-235,272-290` — the existing OpenRe stub grew `openre.session.read` in `OPENRE_CAPS`, a sessions model with `goLive(streamId, {protocol})`/`endLive(id)`, the `session` field on stream views, and the two session routes with the same capability/X-OV-Subject guards and key-free descriptor.
- `test/panel.test.js:64,181,192` — render test (URL on the owner/watcher tile, escaping, no attribute without a session), exact panel CSP + "site keeps its own" test, and a booted test: paired robot, nothing live → placeholder, `goLive` → URL for owner and a viewer, never `ork_`; `endLive` → placeholder again.
- `test/embed-panel.test.js:37,58,98` — client-source assertions (pinned URL, `[data-signaling-url]` gate, `sfu-viewer-consume`), updated embed CSP, and a route test: anonymous public embed carries the live URL, a private robot's embed is 403 before any URL.
- `test/openre.test.js:61` — client-level test: session list query, playback call, owner subject, non-WebRTC session passed o

Changes

ddiff --git a/README.md b/README.md
iindex a9dc25c..7dd77b6 100644
---- a/README.md
++++ b/README.md
@@@ -14,7 +14,7 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato
- 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 front page, `GET /` (`server/web/home.js`): the OpenVibe Frame (`openvibe-shared/shell`) around `openvibe-shared/showcase` sections that say only what works today (pairing, the panel's controls, the gate and the e-stop, people and the queue, the read-only embed, the simulator and the kits with drivers), with its own CSP for the Frame's calls to the Network; the rest of the site keeps `default-src 'self'`. `/shared/` is openvibe-shared's serve handler.
--- 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 panel (`public/panel.js`, no build step) drives with a touch joystick, the keyboard or a gamepad, shows a latency meter and one camera tile per camera the profile lists; the camera stays a placeholder until OpenRe.Stream can play a WHIP-published stream back to a browser.
++- 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 panel (`public/panel.js`, no build step) drives with a touch joystick, the keyboard or a gamepad, shows a latency meter and one camera tile per camera the profile lists; a tile plays the robot's OpenRe WebRTC session when one is live (the mediasoup-client viewer flow OpenVibe.Live runs, against the session's playback descriptor), and stays the placeholder when there is none.
- Server-side ONVIF cameras (`camera.onvif`, `mapping.driver: onvif`): an in-process connector beside the simulator (`server/onvif`) attaches a camera configured with `BOT_ONVIF_CAMERAS`, writes a real `kind: server` device row, translates a `ptz` command into ONVIF `ContinuousMove`/`Stop` (with the profile's deadline as a deadman) and carries the camera's `GetStatus` readout in telemetry. Its credentials are secret references only (`username_ref`/`password_ref`).
- The command audit (`command_audit`, kept 30 days) and the `bot.*` outbox events.
- Operators and viewers from the panel: the owner adds a person to a robot by `@username` (OpenVibe.Network resolves the name to a subject) and removes them again, each listed on the panel's People card. On an open-queue robot a signed-in visitor sees their turn — or their place in line — and can leave the queue, which promotes the next person at once (`POST /robots/:id/operators`, `POST /robots/:id/operators/:subject/remove`, `POST /robots/:id/queue/leave`, all plain forms that work without JavaScript).
@@@ -82,7 +82,7 @@ The embeddable panel is `GET /panel/:id/embed` (framed only by `BOT_EMBED_ORIGIN
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; OpenRe's is `https://ingest.openre.stream/whip`); leave it unset to pair devices without video.
--The publish key is always an ingest key OpenRe.Stream issued for the robot's stream (OpenRe's WHIP ingest admits no other): set `BOT_OPENRE_URL` and Bot mints its own Network service token for audience `openvibe.openre` from its OAuth client (`OV_OAUTH_CLIENT_ID`/`OV_OAUTH_CLIENT_SECRET` against `OV_NETWORK_INTERNAL_URL`), holding `openre.stream.read`, `openre.stream.write`, `openre.key.rotate` and `openre.session.read`/`openre.output.read`/`openre.output.write` — Network's grant to the `bot` client; the session and output capabilities are for later panel video and restreaming. `BOT_OPENRE_TOKEN` (a Network service token with the same grant, or `openre.stream.*` with `openre.key.rotate`) is an optional operator override; `BOT_OPENRE_TIMEOUT_MS`, default 8000, bounds each call. The same token covers the streaming toggles: `GET /api/v1/robots/:id/streaming` reads the stream with `openre.stream.read`, `POST` PATCHes `recording_mode`/`mirror_to_live` with `openre.stream.write`. Pairing creates the robot's OpenRe stream (or rotates the one it has), a credential rotation rotates its key, revoking a device or removing a robot revokes the key and ends the live session. Bot stores the stream id and the key's hint, never the key. With `BOT_OPENRE_URL` unset, or neither `BOT_OPENRE_TOKEN` nor the Network client credentials available, devices still pair but get no publish key and the answer says `"video": "not_configured"`; Bot mints no key of its own.
++The publish key is always an ingest key OpenRe.Stream issued for the robot's stream (OpenRe's WHIP ingest admits no other): set `BOT_OPENRE_URL` and Bot mints its own Network service token for audience `openvibe.openre` from its OAuth client (`OV_OAUTH_CLIENT_ID`/`OV_OAUTH_CLIENT_SECRET` against `OV_NETWORK_INTERNAL_URL`), holding `openre.stream.read`, `openre.stream.write`, `openre.key.rotate` and `openre.session.read`/`openre.output.read`/`openre.output.write` — Network's grant to the `bot` client; `openre.session.read` is the panel's live video (the open session and its playback descriptor) and the output capabilities are for restreaming later. `BOT_OPENRE_TOKEN` (a Network service token with the same grant, or `openre.stream.*` with `openre.key.rotate`) is an optional operator override; `BOT_OPENRE_TIMEOUT_MS`, default 8000, bounds each call. The same token covers the streaming toggles: `GET /api/v1/robots/:id/streaming` reads the stream with `openre.stream.read`, `POST` PATCHes `recording_mode`/`mirror_to_live` with `openre.stream.write`. Pairing creates the robot's OpenRe stream (or rotates the one it has), a credential rotation rotates its key, revoking a device or removing a robot revokes the key and ends the live session. Bot stores the stream id and the key's hint, never the key. With `BOT_OPENRE_URL` unset, or neither `BOT_OPENRE_TOKEN` nor the Network client credentials available, devices still pair but get no publish key and the answer says `"video": "not_configured"`; Bot mints no key of its own.
Set `BOT_BILLING_URL` in `/etc/openvibe/bot.env` to OpenVibe.Billing's base URL and Bot mints its own Network service token for audience `openvibe.billing` (`billing.usage.record`, Network's grant to the `bot` client) from the same OAuth client credentials, posting each job usage reading to `<BOT_BILLING_URL>/api/v1/usage`; `BOT_BILLING_TOKEN` is an optional operator-minted override, and `BOT_BILLING_TIMEOUT_MS`, default 5000, bounds each post. With `BOT_BILLING_URL` unset the relay stays off and the readings wait in `run_usage_outbox`, retried across restarts and never dropped.
ddiff --git a/public/panel.css b/public/panel.css
iindex ef13991..b787a9e 100644
---- a/public/panel.css
++++ b/public/panel.css
@@@ -96,6 +96,9 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf
.camera-icon { width: 2.5rem; height: 2.5rem; stroke-width: 1.5; opacity: 0.6; }
.camera-title { margin: 0; color: var(--text); font-weight: 600; }
.camera-sub { margin: 0; font-size: 0.85rem; max-width: 26rem; }
++.camera-screen { position: relative; }
++/* The live video (public/panel.js) fills the screen once a track plays; the placeholder lines are hidden then. */
++.camera-screen > video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; background: #000; }
.camera figcaption { position: absolute; left: 0.6rem; top: 0.6rem; padding: 0.15rem 0.5rem; border-radius: 6px; background: rgba(0, 0, 0, 0.55); color: #cfd6de; font-size: 0.75rem; letter-spacing: 0.03em; }
/* The drive pad: a joystick for two axes, hold buttons for the rest, a full-width stop. */
ddiff --git a/public/panel.js b/public/panel.js
iindex 53ace66..02ab5cc 100644
---- a/public/panel.js
++++ b/public/panel.js
@@@ -17,6 +17,9 @@
* (and d-pad) drive, right stick rotates when the drive has a rotation axis, A = the first tone,
* B = stop, Start = e-stop; gamepaddisconnected releases it all
* latency round trip from a command's id to its result on /control (rolling median) + telemetry age
++ * camera each tile the server marked with a live OpenRe session's signaling URL plays it: the
++ * mediasoup-client viewer flow OpenVibe.Live runs, reconnecting with backoff; a tile with no
++ * URL is left as the server drew it
*
* A watcher (data-role="watcher": the embed's anonymous, read-only view) joins on /watch instead and only paints
* the state it is sent: no command, e-stop or clear frame is ever sent from it.
@@@ -431,6 +434,137 @@
if (w > 0 && h > 0 && screen) screen.style.aspectRatio = `${w} / ${h}`;
}
++ // ── Live camera: the robot's OpenRe.Stream WebRTC session ────────────────────────────────────────
++ // A tile the server marked with a live session's viewer signaling URL plays it: the mediasoup-client
++ // `sfu-viewer-*` flow OpenVibe.Live's watch page runs against OpenRe's /w/<session id> signaling
++ // (watch → ready → recv transport → consume). mediasoup-client's package ships no browser bundle, so
++ // the pinned ESM build Live loads comes from esm.sh — the panel's own CSP allows exactly that origin.
++ // A tile with no URL is left alone; one whose session ends keeps its placeholder and reconnects with
++ // the same backoff as /control.
++ const MEDIASOUP_URL = 'https://esm.sh/[email protected]';
++ let mediasoup = null;
++ const loadMediasoup = () => (mediasoup ||= import(MEDIASOUP_URL).then(
++ (m) => m.Device || (m.default && m.default.Device),
++ (e) => { mediasoup = null; throw e; }));
++
++ /** The first `type` frame on an OpenRe viewer socket; a viewer error, a close or silence rejects. */
++ function viewerWait(ws, type, timeoutMs = 15000) {
++ return new Promise((resolve, reject) => {
++ if (ws.readyState !== WebSocket.OPEN) return reject(new Error('the camera signaling socket closed'));
++ let timer;
++ const done = (finish) => {
++ clearTimeout(timer);
++ ws.removeEventListener('message', onMessage);
++ ws.removeEventListener('close', onClose);
++ finish();
++ };
++ const onMessage = (e) => {
++ let m; try { m = JSON.parse(e.data); } catch { return; }
++ if (m.type === type) done(() => resolve(m));
++ else if (m.type === 'watch-queued') done(() => reject(new Error('nothing is publishing yet')));
++ else if (m.type === 'sfu-error' || m.type === 'sfu-viewer-error') done(() => reject(new Error(m.error || m.type)));
++ };
++ const onClose = () => done(() => reject(new Error('the camera signaling socket closed')));
++ timer = setTimeout(() => done(() => reject(new Error(`no ${type} from the camera`))), timeoutMs);
++ ws.addEventListener('message', onMessage);
++ ws.addEventListener('close', onClose);
++ });
++ }
++
++ function liveCamera(tile) {
++ const url = tile.dataset.signalingUrl;
++ const screen = $('[data-camera-screen]', tile);
++ if (!url || !screen) return;
++ const placeholder = [...screen.children];
++ const video = document.createElement('video');
++ video.muted = true;
++ video.autoplay = true;
++ video.playsInline = true;
++ video.hidden = true;
++ screen.appendChild(video);
++ let current = null; // the session being connected: { ws, transport, consumers }
++ let timer = null; // the pending reconnect
++ let attempt = 0;
++
++ /** The tile shows the video or its placeholder, never both. */
++ function showLive(on) {
++ video.hidden = !on;
++ for (const el of placeholder) el.hidden = on;
++ }
++ function teardown() {
++ const session = current;
++ current = null;
++ video.srcObject = null;
++ if (!session) return;
++ for (const c of session.consumers) { try { c.close(); } catch { /* gone */ } }
++ if (session.transport) { try { session.transport.close(); } catch { /* gone */ } }
++ if (session.ws) { try { session.ws.close(); } catch { /* gone */ } }
++ }
++ /** The placeholder returns, the session closes, and the next attempt is a backoff away. */
++ function retry() {
++ if (timer) return;
++ showLive(false);
++ teardown();
++ timer = setTimeout(() => { timer = null; connect(); }, Math.min(10000, 500 * 2 ** attempt++));
++ }
++
++ async function connect() {
++ const session = { ws: null, transport: null, consumers: [] };
++ const stale = () => current !== session;
++ current = session;
++ try {
++ const ws = new WebSocket(url);
++ session.ws = ws;
++ await new Promise((open, fail) => {
++ ws.addEventListener('open', open, { once: true });
++ ws.addEventListener('close', () => fail(new Error('the camera signaling socket closed')), { once: true });
++ });
++ if (stale()) return;
++ ws.send(JSON.stringify({ type: 'watch' }));
++ const ready = await viewerWait(ws, 'sfu-viewer-ready');
++ if (stale()) return;
++ if (!ready.producers || !ready.producers.length) throw new Error('the camera has no live track yet');
++ const Device = await loadMediasoup();
++ if (stale()) return;
++ const device = new Device();
++ await device.load({ routerRtpCapabilities: ready.rtpCapabilities });
++ if (stale()) return;
++ ws.send(JSON.stringify({ type: 'sfu-viewer-create-transport' }));
++ const created = await viewerWait(ws, 'sfu-viewer-transport-created');
++ if (stale()) return;
++ const transport = device.createRecvTransport({
++ id: created.id, iceParameters: created.iceParameters, iceCandidates: created.iceCandidates,
++ dtlsParameters: created.dtlsParameters, iceServers: created.iceServers || [],
++ });
++ session.transport = transport;
++ transport.on('connect', ({ dtlsParameters }, cb, errb) => {
++ if (stale()) return errb(new Error('the camera session is gone'));
++ ws.send(JSON.stringify({ type: 'sfu-viewer-connect-transport', transportId: transport.id, dtlsParameters }));
++ viewerWait(ws, 'sfu-viewer-transport-connected').then(() => cb(), () => errb(new Error('the camera transport did not connect')));
++ });
++ const media = new MediaStream();
++ for (const producer of ready.producers) {
++ ws.send(JSON.stringify({ type: 'sfu-viewer-consume', transportId: transport.id, producerId: producer.id, rtpCapabilities: device.rtpCapabilities }));
++ const consumed = await viewerWait(ws, 'sfu-viewer-consumed');
++ if (stale()) return;
++ const consumer = await transport.consume({ id: consumed.id, producerId: consumed.producerId, kind: consumed.kind, rtpParameters: consumed.rtpParameters });
++ session.consumers.push(consumer);
++ media.addTrack(consumer.track);
++ }
++ if (stale()) return;
++ video.srcObject = media;
++ showLive(true);
++ attempt = 0;
++ await video.play().catch(() => { /* autoplay holds the track anyway */ });
++ ws.addEventListener('close', () => { if (!stale()) retry(); });
++ } catch (e) {
++ if (!stale()) retry();
++ }
++ }
++ connect();
++ }
++ for (const tile of $$('[data-signaling-url]')) liveCamera(tile);
++
// ── State ─────────────────────────────────────────────────────────────────────────────────────────
function enableControls() {
for (const section of $$('[data-command]')) {
ddiff --git a/server/domain/index.js b/server/domain/index.js
iindex 3ed1195..856cf63 100644
---- a/server/domain/index.js
++++ b/server/domain/index.js
@@@ -399,6 +399,26 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
});
return streamingState(robot, updated);
}
++ // ── Panel video: the robot's live OpenRe WebRTC session (T15) ──────────────────────────────────
++ /**
++ * The viewer signaling URL of the robot's open WebRTC session, or null. The session list names the
++ * stream's open session — only ever through the robot owner's OpenRe view — and a WebRTC session's
++ * playback descriptor carries `webrtc.signaling_url`, keyed by the session's playback id, never the
++ * ingest key (OpenRe README "playback descriptor"). Null without OpenRe, without a stream, with
++ * nothing publishing, or when the live session is not WebRTC: the panel's tile keeps its
++ * placeholder. OpenRe refusing or not answering throws like any client call; the caller decides.
++ */
++ async function liveVideo(robotId) {
++ if (!openre) return null;
++ const robot = await getRobot(robotId);
++ if (!robot || !robot.openre_stream_id) return null;
++ const open = await openre.sessions(robot.openre_stream_id, robot.owner_subject, { state: 'open' });
++ const session = open.find((s) => s && s.protocol === 'webrtc') || null;
++ if (!session) return null;
++ const descriptor = await openre.playback(session.id, robot.owner_subject);
++ const url = descriptor && descriptor.webrtc && descriptor.webrtc.signaling_url;
++ return typeof url === 'string' && /^wss?:\/\//.test(url) ? url : null;
++ }
async function prunePairingCodes() {
await db.query('DELETE FROM pairing_codes WHERE expires_at < $1', [iso(now() - 24 * 3600 * 1000)]);
}
@@@ -887,6 +907,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
pairing: { create: createPairingCode, redeem, prune: prunePairingCodes, installerCommand, driverForProfile, whipUrl },
devices: { byCredential, bindNode, issuePublishKey, revokeVideo, updateDeclared, revokeNode, ensureServerDevice, get: getDevice, listForRobot: listDevicesForRobot, rotate: rotateDevice, revoke: revokeDevice, touchSeen, setOnline },
streaming: { get: streaming, set: setStreaming },
++ video: { live: liveVideo },
estop: { set: setEstop, clear: clearEstop },
queue: { join: joinQueue, leave: leaveQueue, state: queueState, currentTurn, consume: consumeTurn, sweep: sweepQueues },
audit: { record: auditCommand, list: listAudit, listPage: listAuditPage, prune: pruneAudit },
ddiff --git a/server/openre/client.js b/server/openre/client.js
iindex c2962a7..9374de2 100644
---- a/server/openre/client.js
++++ b/server/openre/client.js
@@@ -10,8 +10,8 @@
*
* By default Bot mints its own Network service token (audience openvibe.openre) from its OAuth client
* credentials, with openre.stream.read (find), openre.stream.write (create, archive, streaming toggles),
-- * openre.key.rotate (rotate) and openre.session.read/openre.output.read/openre.output.write (session status
-- * and outputs, for panel video and restreaming later). An operator-minted BOT_OPENRE_TOKEN overrides it. The
++ * openre.key.rotate (rotate) and openre.session.read/openre.output.read/openre.output.write (the panel's
++ * live video, session status and outputs). An operator-minted BOT_OPENRE_TOKEN overrides it. The
* token is cached until 60 s before expiry; a 401 from OpenRe (rotated key, clock) drops the cached token and
* the call is tried once more.
*
@@@ -22,6 +22,8 @@
* rotate(id, owner, { grace_seconds, end_sessions })
* POST /api/v1/streams/:id/keys/rotate → { key: { id, key, hint }, … }
* archive(id, owner) DELETE /api/v1/streams/:id (OpenRe refuses a live stream: 409)
++ * sessions(streamId, owner, { state }) GET /api/v1/sessions?stream_id=…&state=… → the stream's sessions
++ * playback(id, owner) GET /api/v1/sessions/:id/playback → the descriptor, or null on 404
*
* A refusal (4xx) throws 502 bot.openre_refused with OpenRe's problem code and detail; no answer, a timeout
* or a 5xx throws 503 bot.openre_unavailable. A 404 for a named stream (get, update, rotate, archive) answers null. The token is
@@@ -120,6 +122,16 @@ function createOpenRe(config, { fetchImpl = globalThis.fetch } = {}) {
rotate: (id, owner, { grace_seconds = 0, end_sessions = false } = {}) =>
call('POST', `${stream(id)}/keys/rotate`, { owner, body: { grace_seconds, end_sessions }, named: true }),
archive: (id, owner) => call('DELETE', stream(id), { owner, named: true }),
++ /** The stream's sessions, `state` 'open' (the default: starting, live or ending) or a named one. */
++ async sessions(streamId, owner, { state = 'open' } = {}) {
++ const query = `stream_id=${encodeURIComponent(streamId)}${state ? `&state=${encodeURIComponent(state)}` : ''}`;
++ const out = await call('GET', `/api/v1/sessions?${query}`, { owner });
++ return (out && Array.isArray(out.sessions) && out.sessions) || [];
++ },
++ async playback(id, owner) {
++ const out = await call('GET', `/api/v1/sessions/${encodeURIComponent(id)}/playback`, { owner, named: true });
++ return (out && out.playback) || null;
++ },
};
}
ddiff --git a/server/web/home.js b/server/web/home.js
iindex 7f2b098..bcdf3d7 100644
---- a/server/web/home.js
++++ b/server/web/home.js
@@@ -48,7 +48,7 @@ function sections({ signedIn }) {
actions: signedIn
? [{ label: 'Your robots', href: '/robots', primary: true }, { label: 'How it works', href: '#how' }]
: [{ label: 'Sign in with OpenVibe', href: '/auth/login?next=%2Frobots', primary: true }, { label: 'How it works', href: '#how' }],
-- note: 'Alpha. Live video in the panel arrives when OpenRe.Stream can play a browser (WHIP) stream back; until then each camera keeps its place in the panel without a picture.',
++ note: 'Alpha. A paired robot sends its camera over WebRTC to OpenRe.Stream and the panel plays it live; the first supported kit is the Adeept 4WD Smart Car for Raspberry Pi.',
}) + showcase.steps({
id: 'how',
title: 'Online in three steps',
ddiff --git a/server/web/render.js b/server/web/render.js
iindex 9ff8593..bf15002 100644
---- a/server/web/render.js
++++ b/server/web/render.js
@@@ -7,9 +7,10 @@
* code here. Every value is HTML-escaped; nothing is inline (the CSP is `default-src 'self'`), so the client
* (public/panel.js) reads what it needs from data-* attributes, and the icons are inline SVG markup.
*
-- * renderPanel({ robot, profile, role, allowed_commands, holdResendMs, mode, signedIn, streaming, people })
++ * renderPanel({ robot, profile, role, allowed_commands, holdResendMs, mode, signedIn, streaming, people, signaling_url })
* mode 'embed': the framed panel (no topbar, no owner form, links open a new tab);
-- * `people` (owner page only) the non-owner members for the People card
++ * `people` (owner page only) the non-owner members for the People card;
++ * `signaling_url` the robot's live OpenRe WebRTC session, on the camera tiles
* renderEmbedRefused({ robotId }) the framed answer when the visitor may not see the robot
* renderWidget(widget, { profile, allowed_commands })
* renderRobotsPage({ robots, profiles, error, values })
@@@ -134,24 +135,29 @@ function camerasOf(profile = {}) {
return list.length ? list : [{}];
}
--/** One tile per camera. A camera widget naming one (`camera`: its name or index) shows only that one. */
--function cameraTiles(w, profile) {
++/**
++ * One tile per camera. A camera widget naming one (`camera`: its name or index) shows only that one.
++ * `signaling_url` is the robot's live OpenRe WebRTC session, when one is; the tile carries it (escaped)
++ * so public/panel.js can play it — the server itself emits no script.
++ */
++function cameraTiles(w, profile, signaling_url = null) {
const all = camerasOf(profile).map((c, i) => ({ c, i }));
const pick = w.camera != null ? all.filter(({ c, i }) => c.name === w.camera || i === w.camera) : all;
return (pick.length ? pick : all).map(({ c, i }) => {
const res = typeof c.resolution === 'string' && /^\d+x\d+$/.test(c.resolution) ? c.resolution : '';
const title = c.name || (all.length > 1 ? `Camera ${i + 1}` : '');
const caption = [title, res.replace('x', '×'), c.transport ? String(c.transport).toUpperCase() : ''].filter(Boolean).join(' · ');
-- return `<figure class="camera" data-camera="${esc(i)}"${res ? ` data-resolution="${esc(res)}"` : ''}${c.transport ? ` data-transport="${esc(c.transport)}"` : ''}>`
++ return `<figure class="camera" data-camera="${esc(i)}"${res ? ` data-resolution="${esc(res)}"` : ''}${c.transport ? ` data-transport="${esc(c.transport)}"` : ''}`
++ + `${signaling_url ? ` data-signaling-url="${esc(signaling_url)}"` : ''}>`
+ `<div class="camera-screen" data-camera-screen>${icon('camera', 'icon camera-icon')}`
-- + '<p class="camera-title">Video is not connected yet.</p>'
-- + '<p class="camera-sub">The robot can already send its camera; watching it on this page comes in a later release.</p></div>'
++ + '<p class="camera-title">No live video right now.</p>'
++ + '<p class="camera-sub">The camera shows here while the robot is online and sending video.</p></div>'
+ (caption ? `<figcaption>${esc(caption)}</figcaption>` : '')
+ '</figure>';
}).join('');
}
--function renderWidget(w, { profile = {}, allowed_commands = [] } = {}) {
++function renderWidget(w, { profile = {}, allowed_commands = [], signaling_url = null } = {}) {
const commands = profile.commands || {};
const kind = w.command ? w.command.kind : null;
const enabled = !!kind && allowed_commands.includes(kind);
@@@ -203,7 +209,7 @@ function renderWidget(w, { profile = {}, allowed_commands = [] } = {}) {
+ '</div>';
break;
case 'camera':
-- inner = `<div class="cameras">${cameraTiles(w, profile)}</div>`;
++ inner = `<div class="cameras">${cameraTiles(w, profile, signaling_url)}</div>`;
break;
default:
inner = '';
@@@ -290,12 +296,12 @@ function turnStrip(robot, role) {
return `<div class="turn" data-turn hidden><span class="turn-state" data-turn-state></span><span class="turn-meta" data-turn-meta></span><form method="post" action="/robots/${esc(robot.id)}/queue/leave" data-turn-leave><button type="submit" class="quiet">Leave the queue</button></form></div>`;
}
--function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs = 150, mode = 'page', signedIn = true, streaming = null, people = null }) {
++function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs = 150, mode = 'page', signedIn = true, streaming = null, people = null, signaling_url = null }) {
const embed = mode === 'embed';
const latched = !!(robot.estop && robot.estop.latched);
const widgets = (profile.widgets || []).map((w, i) => ({ w, i }))
.sort((a, b) => (a.w.order != null ? a.w.order : 1e9 + a.i) - (b.w.order != null ? b.w.order : 1e9 + b.i))
-- .map(({ w }) => renderWidget(w, { profile, allowed_commands }));
++ .map(({ w }) => renderWidget(w, { profile, allowed_commands, signaling_url }));
const estop = ESTOP_ROLES.has(role) ? `<button type="button" class="estop" data-estop>${icon('stop')}<span>E-stop</span></button>` : '';
const clear = role === 'owner' ? `<button type="button" class="estop-clear" data-estop-clear${latched ? '' : ' hidden'}>Clear e-stop</button>` : '';
const body = `<header class="estop-banner" data-estop-banner data-latched="${latched}">
ddiff --git a/server/web/routes.js b/server/web/routes.js
iindex 521b906..69d028a 100644
---- a/server/web/routes.js
++++ b/server/web/routes.js
@@@ -20,6 +20,10 @@
* POST /robots/:id/queue/leave any signed-in person: leave the robot's turn queue → 303
* GET /panel/panel.js, /panel/panel.css the panel client and its sheet (public/, no build step)
*
++ * The panel and embed pages carry their own CSP (PANEL_CSP / embedCsp): the live camera plays the robot's
++ * OpenRe WebRTC session with the pinned mediasoup-client ESM build from esm.sh, so those two pages alone
++ * allow that script origin and OpenRe's signaling; the rest of the site keeps `default-src 'self'`.
++ *
* 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. Adding a robot and minting a code count against
* the signed-in person's `bot.robot.manage` limit, the same one /api/v1 applies.
@@@ -35,6 +39,17 @@ const { renderHome, HOME_CSP } = require('./home');
const PUBLIC = path.join(__dirname, '..', '..', 'public');
const HOLD_RESEND_MS = 150;
++// The panel plays the robot's live video with mediasoup-client, whose npm package ships no browser
++// bundle: the page imports the pinned ESM build OpenVibe.Live uses from esm.sh, and its viewer
++// signaling endpoint is OpenRe's wss host (OpenRe README "playback descriptor"). Exactly these two
++// sources, on the panel and embed pages only — the site-wide CSP (server/app.js) stays as it is.
++const PANEL_SCRIPT_SRC = 'https://esm.sh';
++const PANEL_CONNECT_SRC = 'wss://ingest.openre.stream';
++const PANEL_CSP = ["default-src 'self'", `script-src 'self' ${PANEL_SCRIPT_SRC}`, `connect-src 'self' ${PANEL_CONNECT_SRC}`,
++ "frame-ancestors 'self'", "object-src 'none'", "base-uri 'self'"].join('; ');
++/** The embed's own CSP: the panel's sources plus the frame-ancestors allow-list. */
++const embedCsp = (config) => ["default-src 'self'", `script-src 'self' ${PANEL_SCRIPT_SRC}`, `connect-src 'self' ${PANEL_CONNECT_SRC}`,
++ `frame-ancestors ${frameAncestors(config)}`, "object-src 'none'", "base-uri 'self'"].join('; ');
function createWebRoutes(config, { domain = null, sim = null, onvif = null, limits = null, identity = null, hub = null, log = console } = {}) {
const r = express.Router();
@@@ -238,16 +253,23 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
async function sendPanel(res, robot, profile, role, mode = 'page', signedIn = true) {
await attachInProcess(robot, profile);
const { maxCommandMs } = domain.control.effectiveLimits(robot, profile);
-- // Only the owner's own page shows the streaming switches; an OpenRe that does not answer leaves them
-- // greyed out with a reason instead of failing the panel.
-- const streaming = role === 'owner' && mode === 'page'
-- ? await domain.streaming.get(robot.id).catch(() => ({ available: false, reason: 'unavailable', media: { on: false }, live: { on: false } }))
-- : null;
++ // Two OpenRe reads, run together. Only the owner's own page shows the streaming switches, greyed out
++ // with a reason when OpenRe does not answer; the live session is for the camera tile of every page,
++ // where it is left a placeholder instead. A watcher is the anonymous embed and only exists when the
++ // owner set embed_public — a private robot's video reaches no one else.
++ const [streaming, signaling_url] = await Promise.all([
++ role === 'owner' && mode === 'page'
++ ? domain.streaming.get(robot.id).catch(() => ({ available: false, reason: 'unavailable', media: { on: false }, live: { on: false } }))
++ : null,
++ role === 'watcher' && !robot.embed_public
++ ? null
++ : domain.video.live(robot.id).catch((e) => { log.warn(`[Bot] live video for ${robot.id}: ${e.message}`); return null; }),
++ ]);
// The owner's People card: every member but the owner, with Network's name for each. A Network that
// does not answer leaves the subject id and a note (see renderPanel) instead of failing the panel.
const people = role === 'owner' && mode === 'page' ? await peopleFor(robot) : null;
res.type('html').send(renderPanel({
-- robot: presentForPage(robot), profile, role, mode, signedIn, streaming, people,
++ robot: presentForPage(robot), profile, role, mode, signedIn, streaming, people, signaling_url,
allowed_commands: role === 'watcher' ? [] : domain.control.allowedFor(robot, role, profile),
// A held control is re-sent well inside the device's deadline, so it never stops between two frames.
holdResendMs: Math.max(50, Math.min(HOLD_RESEND_MS, Math.floor(maxCommandMs / 2))),
@@@ -269,6 +291,9 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
}
r.get('/panel/:id', page(async (req, res) => {
++ // The panel page's own CSP (the live video's script and signaling sources, nothing else) replaces
++ // the app's for this route only; every other page keeps `default-src 'self'`.
++ res.setHeader('Content-Security-Policy', PANEL_CSP);
const me = requireUser(req);
const robot = await domain.robots.get(req.params.id);
if (!robot) throw new BotError(404, 'bot.robot_not_found', 'no such robot');
@@@ -281,7 +306,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
// The embed's own CSP replaces the app's for this route only; it sets no cookie and needs none to watch.
r.get('/panel/:id/embed', page(async (req, res) => {
-- res.setHeader('Content-Security-Policy', ["default-src 'self'", `frame-ancestors ${frameAncestors(config)}`, "object-src 'none'", "base-uri 'self'"].join('; '));
++ res.setHeader('Content-Security-Policy', embedCsp(config));
const robot = await domain.robots.get(req.params.id);
if (!robot) throw new BotError(404, 'bot.robot_not_found', 'no such robot');
let role = req.viewer ? await memberRole(robot, req.viewer) : null;
ddiff --git a/test/embed-panel.test.js b/test/embed-panel.test.js
iindex e79c37e..c29f9be 100644
---- a/test/embed-panel.test.js
++++ b/test/embed-panel.test.js
@@@ -34,11 +34,17 @@ const { renderPanel } = require('../server/web/render');
assert.ok(page.includes('class="panel-page"') && page.includes('class="topbar"') && page.includes('data-embed-form'));
});
-- await check('the panel client: a watcher joins on /watch and never sends anything but join', () => {
++ await check('the panel client: a watcher joins on /watch and never sends anything but join; a camera tile plays only its URL', () => {
const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'panel.js'), 'utf8');
assert.match(js, /const WATCHER = main\.dataset\.role === 'watcher';/);
assert.match(js, /new WebSocket\(wsUrl\(WATCHER \? '\/watch' : '\/control'\)\)/);
assert.match(js, /if \(WATCHER && frame\.type !== 'join'\) return false;/);
++ // The live camera reads the server's attribute, the pinned mediasoup-client build Live loads, and
++ // the sfu-viewer-* flow; a page with no tile carrying it never imports anything.
++ assert.match(js, /for \(const tile of \$\$\('\[data-signaling-url\]'\)\) liveCamera\(tile\);/);
++ assert.match(js, /import\(MEDIASOUP_URL\)/);
++ assert.match(js, /const MEDIASOUP_URL = 'https:\/\/esm\.sh\/mediasoup-client@3\.18\.7';/);
++ assert.match(js, /type: 'sfu-viewer-consume'/);
});
const t = await boot({ env: { BOT_WATCH_MAX_PER_IP: '2', BOT_WATCH_MAX_PER_ROBOT: '3' } });
@@@ -49,7 +55,7 @@ const { renderPanel } = require('../server/web/render');
let ipN = 0;
// Each watcher from its own address unless one is named (the test server trusts one proxy hop, as production).
const watch = (ip = `10.0.0.${++ipN}`) => t.ws('/watch', { headers: { 'X-Forwarded-For': ip } });
-- const EMBED_CSP = "default-src 'self'; frame-ancestors 'self' https://openvibe.live https://www.openvibe.live; object-src 'none'; base-uri 'self'";
++ const EMBED_CSP = "default-src 'self'; script-src 'self' https://esm.sh; connect-src 'self' wss://ingest.openre.stream; frame-ancestors 'self' https://openvibe.live https://www.openvibe.live; object-src 'none'; base-uri 'self'";
const auditCount = async (id) => (await t.domain.audit.list(id, { limit: 100 })).length;
const { robot: open } = await t.robot(alex, { name: 'Public rover' });
@@@ -89,6 +95,23 @@ const { renderPanel } = require('../server/web/render');
assert.ok(stranger.includes('data-role="watcher"') && !stranger.includes('Sign in to control'));
});
++ await check('a live session\'s signaling URL reaches the anonymous public embed; a private robot is refused before it', async () => {
++ const code = (await t.call('POST', `/api/v1/robots/${open.id}/pairing-code`, { user: alex })).json;
++ const paired = await t.call('POST', '/api/v1/pair', { token: null, body: { robot: open.id, code: code.code } });
++ assert.strictEqual(paired.status, 201, paired.text);
++ const streamId = (await t.db.maybe('SELECT openre_stream_id FROM robots WHERE id = $1', [open.id])).openre_stream_id;
++ const session = t.openre.goLive(streamId);
++ const html = await (await get(`/panel/${open.id}/embed`)).text();
++ assert.ok(html.includes(`data-signaling-url="ws://127.0.0.1:9936/w/${session.id}"`), 'the public embed plays the robot\'s live session');
++ assert.ok(!html.includes(paired.json.publish_key) && !html.includes('ork_'), 'the ingest key is never on the page');
++ t.openre.endLive(session.id);
++ assert.ok(!(await (await get(`/panel/${open.id}/embed`)).text()).includes('data-signaling-url'), 'no session: the placeholder');
++ // The private robot's anonymous embed is refused outright, so its video can reach no one.
++ const refused = await get(`/panel/${closed.id}/embed`);
++ assert.strictEqual(refused.status, 403);
++ assert.ok(!(await refused.text()).includes('data-signaling-url'));
++ });
++
await check('/watch: join answers the public state only, and a robot that is not public (or unknown) is refused', async () => {
const w = await watch();
w.send({ type: 'join', robot_id: closed.id });
ddiff --git a/test/helpers/stubs.js b/test/helpers/stubs.js
iindex cd22a4d..8f13118 100644
---- a/test/helpers/stubs.js
++++ b/test/helpers/stubs.js
@@@ -11,14 +11,17 @@
* and POST /internal/identity/resolve-batch, over the users newUser registers; failIdentity
* makes them answer a failure (Network down).
* startEvents() POST /api/v1/events recording what Bot's outbox relays; GET /api/health
-- * startOpenRe() OpenRe.Stream's stream routes Bot calls (server/api/v1.js there): GET /api/v1/streams?external_ref=,
-- * GET /api/v1/streams/:id, POST /api/v1/streams, PATCH /api/v1/streams/:id (recording_mode /
-- * mirror_to_live), POST …/:id/keys/rotate, DELETE …/:id (409 while live), behind one bearer
-- * token whose capabilities OpenRe's guards check (setCaps(list) to narrow them; 403
-- * capability.denied). With `network` given, a service token Network minted for audience
-- * openvibe.openre is accepted too, its own `cap` claims being the capabilities (the minted
-- * path, 401 token.* otherwise). streams, calls (every request, its token and X-OV-Subject),
-- * admits(key) (OpenRe's resolveIngestKey: an active key or one in grace),
++ * startOpenRe() OpenRe.Stream's stream and session routes Bot calls (server/api/v1.js there):
++ * GET /api/v1/streams?external_ref=, GET /api/v1/streams/:id, POST /api/v1/streams,
++ * PATCH /api/v1/streams/:id (recording_mode / mirror_to_live), POST …/:id/keys/rotate,
++ * DELETE …/:id (409 while live), GET /api/v1/sessions?stream_id=&state= and
++ * GET /api/v1/sessions/:id/playback (a WebRTC session's `webrtc.signaling_url`, never the
++ * key), behind one bearer token whose capabilities OpenRe's guards check (setCaps(list) to
++ * narrow them; 403 capability.denied). With `network` given, a service token Network minted
++ * for audience openvibe.openre is accepted too, its own `cap` claims being the capabilities
++ * (the minted path, 401 token.* otherwise). streams, sessions, calls (every request, its
++ * token and X-OV-Subject), admits(key) (OpenRe's resolveIngestKey: an active key or one in
++ * grace), goLive(streamId, { protocol }) / endLive(id) (a stream's open session),
* failNext(status | 'hang', times = 1)
*/
const http = require('http');
@@@ -180,14 +183,16 @@ async function startEvents() {
}
// The capabilities BOT_OPENRE_TOKEN must hold: GET /streams is openre.stream.read, POST and DELETE
--// openre.stream.write, keys/rotate openre.key.rotate (OpenRe server/api/v1.js guards).
--const OPENRE_CAPS = ['openre.stream.read', 'openre.stream.write', 'openre.key.rotate'];
++// openre.stream.write, keys/rotate openre.key.rotate, sessions and playback openre.session.read
++// (OpenRe server/api/v1.js guards).
++const OPENRE_CAPS = ['openre.stream.read', 'openre.stream.write', 'openre.key.rotate', 'openre.session.read'];
async function startOpenRe({ token = `ovt_${crypto.randomBytes(16).toString('hex')}`, caps = OPENRE_CAPS, network = null } = {}) {
let granted = [...caps];
// OpenRe's hasCap: the exact capability or a `.*` grant covering it.
const hasCap = (caps_, id) => caps_.some((g) => g === id || (g.endsWith('.*') && id.startsWith(g.slice(0, -1))));
const streams = new Map();
++ const sessions = new Map();
const calls = [];
let failure = null;
let failuresLeft = 0; // how many further requests failNext answers before it clears
@@@ -196,8 +201,41 @@ async function startOpenRe({ token = `ovt_${crypto.randomBytes(16).toString('hex
const key = `ork_${crypto.randomBytes(32).toString('base64url')}`;
return { id: `key_${++n}`, key, hint: key.slice(-4), status: 'active', grace_until: null };
};
++ /** The stream's open session, as GET /streams/:id reports it (OpenRe publicDefinition.session). */
++ const openFor = (streamId) => [...sessions.values()].find((s) => s.stream_id === streamId && s.state !== 'ended') || null;
++ const sessionView = (s) => ({ id: s.id, stream_id: s.stream_id, protocol: s.protocol, state: s.state, live_at: s.live_at });
++ /** A session's playback descriptor: for WebRTC, the viewer signaling URL keyed by the playback id. */
++ const playbackOf = (s) => ({
++ session_id: s.id, protocol: s.protocol, state: s.state, live: s.state === 'live',
++ worker: { id: 'wrk_test', kind: s.protocol, generation: 1, state: 'up' }, thumbnail_url: null,
++ flv: null, rtmp: null, jsmpeg: null, hls: null,
++ webrtc: s.protocol === 'webrtc' ? { signaling_url: `ws://127.0.0.1:9936/w/${s.id}`, announced_ip: '203.0.113.7' } : null,
++ });
++ /**
++ * A stream goes live (tests): the open session the session list and the playback descriptor point at,
++ * one per stream as OpenRe allows. `protocol` may be 'rtmp' to check a non-WebRTC session is passed
++ * over. endLive(id) ends it; both keep the stream's `live` flag in step.
++ */
++ function goLive(streamId, { protocol = 'webrtc' } = {}) {
++ const st = streams.get(streamId);
++ if (!st) return null;
++ for (const open of [...sessions.values()]) if (open.stream_id === streamId && open.state !== 'ended') endLive(open.id);
++ const session = { id: `ses_${crypto.randomBytes(13).toString('hex').toUpperCase()}`, stream_id: streamId, protocol, state: 'live', live_at: new Date().toISOString() };
++ sessions.set(session.id, session);
++ st.live = true;
++ return session;
++ }
++ function endLive(id) {
++ const session = sessions.get(id);
++ if (!session || session.state === 'ended') return session || null;
++ session.state = 'ended';
++ const st = streams.get(session.stream_id);
++ if (st && ![...sessions.values()].some((x) => x.stream_id === st.id && x.state !== 'ended')) st.live = false;
++ return session;
++ }
const view = (st) => ({ id: st.id, owner: { type: 'user', id: st.owner }, protocols: st.protocols, state: st.state, external_refs: st.refs,
recording_mode: st.recording_mode, recording_visibility: st.recording_visibility, playback_visibility: st.playback_visibility, mirror_to_live: st.mirror_to_live,
++ session: (() => { const open = openFor(st.id); return open ? { id: open.id, state: open.state, protocol: open.protocol, live_at: open.live_at } : null; })(),
keys: st.keys.filter((k) => k.status !== 'revoked').map((k) => ({ id: k.id, hint: k.hint, status: k.status })) });
const problem = (res, status, code, detail) => { res.writeHead(status, { 'Content-Type': 'application/problem+json' }); res.end(JSON.stringify({ type: 'about:blank', status, code, detail })); };
function admits(key) {
@@@ -231,10 +269,25 @@ async function startOpenRe({ token = `ovt_${crypto.randomBytes(16).toString('hex
}
if (!shownCaps) return problem(res, 401, 'token.invalid', 'a service token is required');
const m = /^\/api\/v1\/streams(?:\/([^/]+)(\/keys\/rotate)?)?$/.exec(url.pathname);
-- const need = m && (m[2] ? 'openre.key.rotate' : req.method === 'GET' ? 'openre.stream.read' : 'openre.stream.write');
++ const sm = /^\/api\/v1\/sessions(?:\/([^/]+)\/playback)?$/.exec(url.pathname);
++ const need = sm ? 'openre.session.read'
++ : m && (m[2] ? 'openre.key.rotate' : req.method === 'GET' ? 'openre.stream.read' : 'openre.stream.write');
if (need && !hasCap(shownCaps, need)) return problem(res, 403, 'capability.denied', `${need} not granted`);
if (!subject || !/^usr_/.test(subject)) return problem(res, 400, 'subject.invalid', 'X-OV-Subject must be a usr_… subject id');
const mine = (id) => { const st = streams.get(id); return st && st.state !== 'archived' && st.owner === subject ? st : null; };
++ if (sm && req.method === 'GET') {
++ if (!sm[1]) {
++ const streamId = String(url.searchParams.get('stream_id') || '');
++ if (streamId && !mine(streamId)) return problem(res, 404, 'openre.stream_not_found', 'no such stream definition');
++ const state = url.searchParams.get('state');
++ const list = [...sessions.values()].filter((s) => (!streamId || s.stream_id === streamId) && mine(s.stream_id)
++ && (!state || (state === 'open' ? s.state !== 'ended' : s.state === state)));
++ return send(res, 200, { sessions: list.map(sessionView) });
++ }
++ const session = sessions.get(decodeURIComponent(sm[1]));
++ if (!session || !mine(session.stream_id)) return problem(res, 404, 'openre.session_not_found', 'no such session');
++ return send(res, 200, { playback: playbackOf(session) });
++ }
if (url.pathname === '/api/v1/streams' && req.method === 'GET') {
const [service, type, ...rest] = String(url.searchParams.get('external_ref') || '').split(':');
const st = [...streams.values()].find((x) => x.state !== 'archived' && x.owner === subject && x.refs.some((r) => r.service === service && r.type === type && r.id === rest.join(':')));
@@@ -280,7 +333,7 @@ async function startOpenRe({ token = `ovt_${crypto.randomBytes(16).toString('hex
});
const url = await listen(server);
return {
-- url, token, streams, calls, admits,
++ url, token, streams, sessions, calls, admits, goLive, endLive,
failNext: (what, times = 1) => { failure = what; failuresLeft = what == null ? 0 : times; },
setCaps: (list) => { granted = [...(list || caps)]; },
close: () => { server.closeAllConnections(); return new Promise((r) => server.close(r)); },
ddiff --git a/test/home.test.js b/test/home.test.js
iindex 0c0f3df..9bafb73 100644
---- a/test/home.test.js
++++ b/test/home.test.js
@@@ -36,7 +36,7 @@ const { boot, check, done } = require('./helpers/app');
await check('GET /: no promise the service cannot keep (video waits for OpenRe; no store, no unsupported boards)', async () => {
const html = await (await get('/')).text();
-- assert.match(html, /Live video in the panel arrives when OpenRe\.Stream can play a browser \(WHIP\) stream back/);
++ assert.match(html, /sends its camera over WebRTC to OpenRe\.Stream and the panel plays it live/);
for (const claim of [/ESP32/, /ROS 2/, /Android/, /arrive paired/i, /\bbuy\b/i, /webhook/i]) assert.ok(!claim.test(html), `no ${claim}`);
});
ddiff --git a/test/openre.test.js b/test/openre.test.js
iindex c7cb490..e100982 100644
---- a/test/openre.test.js
++++ b/test/openre.test.js
@@@ -58,6 +58,32 @@ const WHIP_BASE = 'https://ingest.test/whip';
assert.deepStrictEqual(cols.map((c) => `${c.table_name}.${c.column_name}`).sort(), ['devices.publish_key_hint', 'robots.openre_stream_id']);
});
++ await check('the panel video look-up reads the stream\'s open session and its playback descriptor, always as the owner', async () => {
++ const { robot } = await pairedRobot();
++ const streamId = (await robotRow(robot.id)).openre_stream_id;
++ // The session's viewer URL, keyed by its playback id; the calls act for the robot's owner.
++ const session = t.openre.goLive(streamId);
++ assert.strictEqual(await t.domain.video.live(robot.id), `ws://127.0.0.1:9936/w/${session.id}`);
++ const list = t.openre.calls.filter((c) => c.path === '/api/v1/sessions').at(-1);
++ assert.strictEqual(list.query, `?stream_id=${encodeURIComponent(streamId)}&state=open`);
++ assert.strictEqual(list.subject, alex.subject);
++ assert.strictEqual(list.authorization, `Bearer ${t.openre.token}`);
++ const playback = t.openre.calls.filter((c) => c.path === `/api/v1/sessions/${session.id}/playback`).at(-1);
++ assert.strictEqual(playback.subject, alex.subject);
++ assert.ok(!JSON.stringify(playback).includes('ork_'), 'never the ingest key');
++ // A live session that is not WebRTC has no viewer URL, and a robot with no stream asks nothing.
++ t.openre.endLive(session.id);
++ const rtmp = t.openre.goLive(streamId, { protocol: 'rtmp' });
++ assert.strictEqual(await t.domain.video.live(robot.id), null, 'a non-WebRTC session is passed over');
++ t.openre.endLive(rtmp.id);
++ const quiet = t.openre.calls.length;
++ assert.strictEqual(await t.domain.video.live(robot.id), null);
++ assert.strictEqual(t.openre.calls.length, quiet + 1, 'nothing open: only the session list is read');
++ const { robot: bare } = await t.robot(alex, { name: 'No stream yet' });
++ assert.strictEqual(await t.domain.video.live(bare.id), null);
++ assert.strictEqual(t.openre.calls.length, quiet + 1, 'no stream: OpenRe is not asked');
++ });
++
await check('pairing creates the robot\'s OpenRe stream and hands the device the ingest key OpenRe issued', async () => {
const before = t.openre.calls.length;
const { robot, paired } = await pairedRobot();
ddiff --git a/test/panel.test.js b/test/panel.test.js
iindex 22fec82..ad2f7c6 100644
---- a/test/panel.test.js
++++ b/test/panel.test.js
@@@ -45,7 +45,7 @@ const allowedFor = (profile, role) => [...(DEFAULT_ALLOW[role] || []).filter((k)
for (const p of profiles.filter((x) => x.widgets.some((w) => w.type === 'camera'))) {
const html = renderPanel({ robot: robotFor(p), profile: p, role: 'owner', allowed_commands: allowedFor(p, 'owner') });
assert.strictEqual(tiles(html), camerasOf(p).length, p.id);
-- assert.ok(html.includes('Video is not connected yet.'), p.id);
++ assert.ok(html.includes('No live video right now.'), p.id);
if (p.camera && p.camera.resolution) assert.ok(html.includes(`data-resolution="${p.camera.resolution}"`), p.id);
assert.doesNotMatch(html, /<video|whip_url|publish_key/, `${p.id}: no stream address on the page`);
}
@@@ -61,6 +61,19 @@ const allowedFor = (profile, role) => [...(DEFAULT_ALLOW[role] || []).filter((k)
assert.strictEqual(tiles(renderWidget({ type: 'camera' }, { profile: { ...p, camera: null } })), 1);
});
++ await check('a live OpenRe session rides on the camera tile as its signaling URL, escaped; none given, no attribute', () => {
++ const p = profiles.find((x) => x.id === 'sim.rover');
++ const url = 'wss://ingest.openre.stream/w/ses_01J5Z000000000000000000000';
++ const owner = renderPanel({ robot: robotFor(p), profile: p, role: 'owner', allowed_commands: allowedFor(p, 'owner'), signaling_url: url });
++ assert.ok(owner.includes(`data-signaling-url="${url}"`), 'the owner\'s tile carries the URL');
++ const watcher = renderPanel({ robot: robotFor(p), profile: p, role: 'watcher', mode: 'embed', signedIn: false, signaling_url: url });
++ assert.ok(watcher.includes(`data-signaling-url="${url}"`), 'a watcher the route admits plays it too');
++ const idle = renderPanel({ robot: robotFor(p), profile: p, role: 'owner', allowed_commands: allowedFor(p, 'owner') });
++ assert.ok(!idle.includes('data-signaling-url') && idle.includes('No live video right now.'), 'no session: the placeholder stays');
++ const evil = renderPanel({ robot: robotFor(p), profile: p, role: 'owner', allowed_commands: [], signaling_url: '"><img src=x onerror=alert(1)>' });
++ assert.ok(!evil.includes('<img') && evil.includes('&quot;&gt;&lt;img'), 'the URL is escaped like every other value');
++ });
++
await check('a drive widget is a joystick for two of its axes, hold buttons for the rest, and the latency meter', () => {
const attr = (html, name) => JSON.parse(new RegExp(`${name}="([^"]+)"`).exec(html)[1].replace(/&quot;/g, '"'));
for (const p of profiles) {
@@@ -165,6 +178,37 @@ const allowedFor = (profile, role) => [...(DEFAULT_ALLOW[role] || []).filter((k)
assert.deepStrictEqual(simEvents, [], 'a simulator is reported online in the outbox');
});
++ await check('the panel\'s CSP opens exactly esm.sh and OpenRe\'s signaling; the rest of the site keeps its own', async () => {
++ const csp = (await get(`/panel/${simId}`, alex)).headers.get('content-security-policy');
++ assert.strictEqual(csp,
++ "default-src 'self'; script-src 'self' https://esm.sh; connect-src 'self' wss://ingest.openre.stream; frame-ancestors 'self'; object-src 'none'; base-uri 'self'");
++ for (const p of ['/robots', `/pair/${simId}`]) {
++ const other = (await get(p, alex)).headers.get('content-security-policy') || '';
++ assert.ok(!other.includes('esm.sh') && !other.includes('ingest.openre.stream'), `${p} was widened`);
++ }
++ // The client imports the same pinned build OpenVibe.Live loads.
++ assert.ok((await (await get('/panel/panel.js')).text()).includes('https://esm.sh/[email protected]'));
++ });
++
++ await check('a live OpenRe WebRTC session: the owner\'s and a member\'s panel carry its signaling URL, never the key', async () => {
++ const { robot, pairing } = await t.robot(alex, { name: 'Live cam' });
++ const paired = await t.call('POST', '/api/v1/pair', { token: null, body: { robot: robot.id, code: pairing.code } });
++ assert.strictEqual(paired.status, 201, paired.text);
++ const streamId = (await t.db.maybe('SELECT openre_stream_id FROM robots WHERE id = $1', [robot.id])).openre_stream_id;
++ let html = await (await get(`/panel/${robot.id}`, alex)).text();
++ assert.ok(!html.includes('data-signaling-url'), 'nothing live: the tile stays a placeholder');
++ const session = t.openre.goLive(streamId);
++ html = await (await get(`/panel/${robot.id}`, alex)).text();
++ assert.ok(html.includes(`data-signaling-url="ws://127.0.0.1:9936/w/${session.id}"`), html);
++ assert.ok(!html.includes(paired.json.publish_key) && !html.includes('ork_'), 'the ingest key is never on the page');
++ assert.strictEqual(t.openre.calls.filter((c) => c.path === `/api/v1/sessions/${session.id}/playback`).at(-1).subject, alex.subject, 'OpenRe is asked as the robot\'s owner');
++ await t.call('POST', `/api/v1/robots/${robot.id}/operators`, { user: alex, body: { subject: carol.subject, role: 'viewer' } });
++ assert.ok((await (await get(`/panel/${robot.id}`, carol)).text()).includes(`data-signaling-url="ws://127.0.0.1:9936/w/${session.id}"`), 'a viewer member plays it too');
++ t.openre.endLive(session.id);
++ html = await (await get(`/panel/${robot.id}`, alex)).text();
++ assert.ok(!html.includes('data-signaling-url'), 'a session that ended leaves the placeholder');
++ });
++
await check('a sim robot has telemetry within 1 s and acks a command; the deadman stops it', async () => {
const { robot } = await t.robot(alex, { name: 'Sim 2' });
assert.strictEqual(await t.app.locals.sim.attach(robot.id), true);