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)
Changes
@@@ -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_ORIGINSet `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.@@@ -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. */@@@ -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]')) {@@@ -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 },@@@ -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;++ },};}@@@ -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',@@@ -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}">@@@ -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, limiasync 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;@@@ -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 });@@@ -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('hexconst 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)); },@@@ -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}`);});@@@ -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();@@@ -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('"><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(/"/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);
@@ -14,7 +14,7 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato
@@ -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.
- Pairing: one-time codes with a short expiry, minted by Bot or, with `BOT_PAIRING_AUTHORITY=network`, by OpenVibe.Network.
- The `/device` and `/control` WebSockets, the command gate and the latched e-stop.
- The `/device` and `/control` WebSockets, the command gate and the latched e-stop.
- The 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 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`).
- 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.
- 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).
- 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
@@ -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.
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.
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.
@@ -96,6 +96,9 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf
@@ -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-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-title { margin: 0; color: var(--text); font-weight: 600; }
.camera-sub { margin: 0; font-size: 0.85rem; max-width: 26rem; }
.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; }
.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. */
/* The drive pad: a joystick for two axes, hold buttons for the rest, a full-width stop. */
@@ -17,6 +17,9 @@
@@ -17,6 +17,9 @@
* (and d-pad) drive, right stick rotates when the drive has a rotation axis, A = the first tone,
* (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
* 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
* 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
* 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.
* the state it is sent: no command, e-stop or clear frame is ever sent from it.
@@ -431,6 +434,137 @@
@@ -431,6 +434,137 @@
if (w > 0 && h > 0 && screen) screen.style.aspectRatio = `${w} / ${h}`;
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 ─────────────────────────────────────────────────────────────────────────────────────────
// ── State ─────────────────────────────────────────────────────────────────────────────────────────
function enableControls() {
function enableControls() {
for (const section of $$('[data-command]')) {
for (const section of $$('[data-command]')) {
@@ -399,6 +399,26 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -399,6 +399,26 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
});
});
return streamingState(robot, updated);
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() {
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)]);
}
}
@@ -887,6 +907,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -887,6 +907,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
pairing: { create: createPairingCode, redeem, prune: prunePairingCodes, installerCommand, driverForProfile, whipUrl },
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 },
devices: { byCredential, bindNode, issuePublishKey, revokeVideo, updateDeclared, revokeNode, ensureServerDevice, get: getDevice, listForRobot: listDevicesForRobot, rotate: rotateDevice, revoke: revokeDevice, touchSeen, setOnline },
streaming: { get: streaming, set: setStreaming },
streaming: { get: streaming, set: setStreaming },
+ video: { live: liveVideo },
estop: { set: setEstop, clear: clearEstop },
estop: { set: setEstop, clear: clearEstop },
queue: { join: joinQueue, leave: leaveQueue, state: queueState, currentTurn, consume: consumeTurn, sweep: sweepQueues },
queue: { join: joinQueue, leave: leaveQueue, state: queueState, currentTurn, consume: consumeTurn, sweep: sweepQueues },
audit: { record: auditCommand, list: listAudit, listPage: listAuditPage, prune: pruneAudit },
audit: { record: auditCommand, list: listAudit, listPage: listAuditPage, prune: pruneAudit },
@@ -10,8 +10,8 @@
@@ -10,8 +10,8 @@
*
*
* By default Bot mints its own Network service token (audience openvibe.openre) from its OAuth client
* 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),
* 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
* 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.
* the call is tried once more.
*
*
@@ -22,6 +22,8 @@
@@ -22,6 +22,8 @@
* rotate(id, owner, { grace_seconds, end_sessions })
* rotate(id, owner, { grace_seconds, end_sessions })
* POST /api/v1/streams/:id/keys/rotate → { key: { id, key, hint }, … }
* 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)
* 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
* 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
* 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 } = {}) {
@@ -120,6 +122,16 @@ function createOpenRe(config, { fetchImpl = globalThis.fetch } = {}) {
rotate: (id, owner, { grace_seconds = 0, end_sessions = false } = {}) =>
rotate: (id, owner, { grace_seconds = 0, end_sessions = false } = {}) =>
call('POST', `${stream(id)}/keys/rotate`, { owner, body: { grace_seconds, end_sessions }, named: true }),
call('POST', `${stream(id)}/keys/rotate`, { owner, body: { grace_seconds, end_sessions }, named: true }),
archive: (id, owner) => call('DELETE', stream(id), { owner, 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;
+ },
};
};
}
}
@@ -48,7 +48,7 @@ function sections({ signedIn }) {
@@ -48,7 +48,7 @@ function sections({ signedIn }) {
actions: signedIn
actions: signedIn
? [{ label: 'Your robots', href: '/robots', primary: true }, { label: 'How it works', href: '#how' }]
? [{ 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' }],
: [{ 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({
}) + showcase.steps({
id: 'how',
id: 'how',
title: 'Online in three steps',
title: 'Online in three steps',
@@ -7,9 +7,10 @@
@@ -7,9 +7,10 @@
* code here. Every value is HTML-escaped; nothing is inline (the CSP is `default-src 'self'`), so the client
* 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.
* (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);
* 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
* renderEmbedRefused({ robotId }) the framed answer when the visitor may not see the robot
* renderWidget(widget, { profile, allowed_commands })
* renderWidget(widget, { profile, allowed_commands })
* renderRobotsPage({ robots, profiles, error, values })
* renderRobotsPage({ robots, profiles, error, values })
@@ -134,24 +135,29 @@ function camerasOf(profile = {}) {
@@ -134,24 +135,29 @@ function camerasOf(profile = {}) {
return list.length ? list : [{}];
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 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;
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 }) => {
return (pick.length ? pick : all).map(({ c, i }) => {
const res = typeof c.resolution === 'string' && /^\d+x\d+$/.test(c.resolution) ? c.resolution : '';
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 title = c.name || (all.length > 1 ? `Camera ${i + 1}` : '');
const caption = [title, res.replace('x', '×'), c.transport ? String(c.transport).toUpperCase() : ''].filter(Boolean).join(' · ');
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')}`
+ `<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>` : '')
+ (caption ? `<figcaption>${esc(caption)}</figcaption>` : '')
+ '</figure>';
+ '</figure>';
}).join('');
}).join('');
}
}
-function renderWidget(w, { profile = {}, allowed_commands = [] } = {}) {
+function renderWidget(w, { profile = {}, allowed_commands = [], signaling_url = null } = {}) {
const commands = profile.commands || {};
const commands = profile.commands || {};
const kind = w.command ? w.command.kind : null;
const kind = w.command ? w.command.kind : null;
const enabled = !!kind && allowed_commands.includes(kind);
const enabled = !!kind && allowed_commands.includes(kind);
@@ -203,7 +209,7 @@ function renderWidget(w, { profile = {}, allowed_commands = [] } = {}) {
@@ -203,7 +209,7 @@ function renderWidget(w, { profile = {}, allowed_commands = [] } = {}) {
+ '</div>';
+ '</div>';
break;
break;
case 'camera':
case 'camera':
- inner = `<div class="cameras">${cameraTiles(w, profile)}</div>`;
+ inner = `<div class="cameras">${cameraTiles(w, profile, signaling_url)}</div>`;
break;
break;
default:
default:
inner = '';
inner = '';
@@ -290,12 +296,12 @@ function turnStrip(robot, role) {
@@ -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>`;
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 embed = mode === 'embed';
const latched = !!(robot.estop && robot.estop.latched);
const latched = !!(robot.estop && robot.estop.latched);
const widgets = (profile.widgets || []).map((w, i) => ({ w, i }))
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))
.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 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 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}">
const body = `<header class="estop-banner" data-estop-banner data-latched="${latched}">
@@ -20,6 +20,10 @@
@@ -20,6 +20,10 @@
* POST /robots/:id/queue/leave any signed-in person: leave the robot's turn queue → 303
* 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)
* 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).
* 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
* 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.
* 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');
@@ -35,6 +39,17 @@ const { renderHome, HOME_CSP } = require('./home');
const PUBLIC = path.join(__dirname, '..', '..', 'public');
const PUBLIC = path.join(__dirname, '..', '..', 'public');
const HOLD_RESEND_MS = 150;
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 } = {}) {
function createWebRoutes(config, { domain = null, sim = null, onvif = null, limits = null, identity = null, hub = null, log = console } = {}) {
const r = express.Router();
const r = express.Router();
@@ -238,16 +253,23 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
@@ -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) {
async function sendPanel(res, robot, profile, role, mode = 'page', signedIn = true) {
await attachInProcess(robot, profile);
await attachInProcess(robot, profile);
const { maxCommandMs } = domain.control.effectiveLimits(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
// 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.
// 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;
const people = role === 'owner' && mode === 'page' ? await peopleFor(robot) : null;
res.type('html').send(renderPanel({
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),
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.
// 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))),
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
@@ -269,6 +291,9 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
}
}
r.get('/panel/:id', page(async (req, res) => {
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 me = requireUser(req);
const robot = await domain.robots.get(req.params.id);
const robot = await domain.robots.get(req.params.id);
if (!robot) throw new BotError(404, 'bot.robot_not_found', 'no such robot');
if (!robot) throw new BotError(404, 'bot.robot_not_found', 'no such robot');
@@ -281,7 +306,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
@@ -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.
// 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) => {
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);
const robot = await domain.robots.get(req.params.id);
if (!robot) throw new BotError(404, 'bot.robot_not_found', 'no such robot');
if (!robot) throw new BotError(404, 'bot.robot_not_found', 'no such robot');
let role = req.viewer ? await memberRole(robot, req.viewer) : null;
let role = req.viewer ? await memberRole(robot, req.viewer) : null;
@@ -34,11 +34,17 @@ const { renderPanel } = require('../server/web/render');
@@ -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'));
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');
const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'panel.js'), 'utf8');
assert.match(js, /const WATCHER = main\.dataset\.role === 'watcher';/);
assert.match(js, /const WATCHER = main\.dataset\.role === 'watcher';/);
assert.match(js, /new WebSocket\(wsUrl\(WATCHER \? '\/watch' : '\/control'\)\)/);
assert.match(js, /new WebSocket\(wsUrl\(WATCHER \? '\/watch' : '\/control'\)\)/);
assert.match(js, /if \(WATCHER && frame\.type !== 'join'\) return false;/);
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' } });
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');
@@ -49,7 +55,7 @@ const { renderPanel } = require('../server/web/render');
let ipN = 0;
let ipN = 0;
// Each watcher from its own address unless one is named (the test server trusts one proxy hop, as production).
// 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 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 auditCount = async (id) => (await t.domain.audit.list(id, { limit: 100 })).length;
const { robot: open } = await t.robot(alex, { name: 'Public rover' });
const { robot: open } = await t.robot(alex, { name: 'Public rover' });
@@ -89,6 +95,23 @@ const { renderPanel } = require('../server/web/render');
@@ -89,6 +95,23 @@ const { renderPanel } = require('../server/web/render');
assert.ok(stranger.includes('data-role="watcher"') && !stranger.includes('Sign in to control'));
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 () => {
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();
const w = await watch();
w.send({ type: 'join', robot_id: closed.id });
w.send({ type: 'join', robot_id: closed.id });
@@ -11,14 +11,17 @@
@@ -11,14 +11,17 @@
* and POST /internal/identity/resolve-batch, over the users newUser registers; failIdentity
* and POST /internal/identity/resolve-batch, over the users newUser registers; failIdentity
* makes them answer a failure (Network down).
* makes them answer a failure (Network down).
* startEvents() POST /api/v1/events recording what Bot's outbox relays; GET /api/health
* 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)
* failNext(status | 'hang', times = 1)
*/
*/
const http = require('http');
const http = require('http');
@@ -180,14 +183,16 @@ async function startEvents() {
@@ -180,14 +183,16 @@ async function startEvents() {
}
}
// The capabilities BOT_OPENRE_TOKEN must hold: GET /streams is openre.stream.read, POST and DELETE
// 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 } = {}) {
async function startOpenRe({ token = `ovt_${crypto.randomBytes(16).toString('hex')}`, caps = OPENRE_CAPS, network = null } = {}) {
let granted = [...caps];
let granted = [...caps];
// OpenRe's hasCap: the exact capability or a `.*` grant covering it.
// 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 hasCap = (caps_, id) => caps_.some((g) => g === id || (g.endsWith('.*') && id.startsWith(g.slice(0, -1))));
const streams = new Map();
const streams = new Map();
+ const sessions = new Map();
const calls = [];
const calls = [];
let failure = null;
let failure = null;
let failuresLeft = 0; // how many further requests failNext answers before it clears
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
@@ -196,8 +201,41 @@ async function startOpenRe({ token = `ovt_${crypto.randomBytes(16).toString('hex
const key = `ork_${crypto.randomBytes(32).toString('base64url')}`;
const key = `ork_${crypto.randomBytes(32).toString('base64url')}`;
return { id: `key_${++n}`, key, hint: key.slice(-4), status: 'active', grace_until: null };
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,
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,
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 })) });
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 })); };
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) {
function admits(key) {
@@ -231,10 +269,25 @@ async function startOpenRe({ token = `ovt_${crypto.randomBytes(16).toString('hex
@@ -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');
if (!shownCaps) return problem(res, 401, 'token.invalid', 'a service token is required');
const m = /^\/api\/v1\/streams(?:\/([^/]+)(\/keys\/rotate)?)?$/.exec(url.pathname);
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 (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');
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; };
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') {
if (url.pathname === '/api/v1/streams' && req.method === 'GET') {
const [service, type, ...rest] = String(url.searchParams.get('external_ref') || '').split(':');
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(':')));
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
@@ -280,7 +333,7 @@ async function startOpenRe({ token = `ovt_${crypto.randomBytes(16).toString('hex
});
});
const url = await listen(server);
const url = await listen(server);
return {
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; },
failNext: (what, times = 1) => { failure = what; failuresLeft = what == null ? 0 : times; },
setCaps: (list) => { granted = [...(list || caps)]; },
setCaps: (list) => { granted = [...(list || caps)]; },
close: () => { server.closeAllConnections(); return new Promise((r) => server.close(r)); },
close: () => { server.closeAllConnections(); return new Promise((r) => server.close(r)); },
@@ -36,7 +36,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -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 () => {
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();
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}`);
for (const claim of [/ESP32/, /ROS 2/, /Android/, /arrive paired/i, /\bbuy\b/i, /webhook/i]) assert.ok(!claim.test(html), `no ${claim}`);
});
});
@@ -58,6 +58,32 @@ const WHIP_BASE = 'https://ingest.test/whip';
@@ -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']);
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 () => {
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 before = t.openre.calls.length;
const { robot, paired } = await pairedRobot();
const { robot, paired } = await pairedRobot();
@@ -45,7 +45,7 @@ const allowedFor = (profile, role) => [...(DEFAULT_ALLOW[role] || []).filter((k)
@@ -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'))) {
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') });
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.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);
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`);
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)
@@ -61,6 +61,19 @@ const allowedFor = (profile, role) => [...(DEFAULT_ALLOW[role] || []).filter((k)
assert.strictEqual(tiles(renderWidget({ type: 'camera' }, { profile: { ...p, camera: null } })), 1);
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('"><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', () => {
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(/"/g, '"'));
const attr = (html, name) => JSON.parse(new RegExp(`${name}="([^"]+)"`).exec(html)[1].replace(/"/g, '"'));
for (const p of profiles) {
for (const p of profiles) {
@@ -165,6 +178,37 @@ const allowedFor = (profile, role) => [...(DEFAULT_ALLOW[role] || []).filter((k)
@@ -165,6 +178,37 @@ const allowedFor = (profile, role) => [...(DEFAULT_ALLOW[role] || []).filter((k)
assert.deepStrictEqual(simEvents, [], 'a simulator is reported online in the outbox');
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 () => {
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' });
const { robot } = await t.robot(alex, { name: 'Sim 2' });
assert.strictEqual(await t.app.locals.sim.attach(robot.id), true);
assert.strictEqual(await t.app.locals.sim.attach(robot.id), true);