openvibers/openvibe.bot Commits
Serve the embeddable panel and an anonymous read-only /watch socket
Changes
@@@ -93,3 +93,6 @@ OV_OAUTH_CLIENT_SECRET=# An invalid entry stops the service at boot. Default: OpenVibe.Live. A robot is embeddable only after its owner# turns on "embed_public" on its panel.#BOT_EMBED_ORIGINS=https://openvibe.live,https://www.openvibe.live++# The anonymous read-only /watch socket behind the embed: open sockets per client address and watchers per robot.++#BOT_WATCH_MAX_PER_IP=20++#BOT_WATCH_MAX_PER_ROBOT=500@@@ -57,7 +57,7 @@ A person acts on their own robots with a Network user token; a service acts with## Deploy--Production runs the Node process under systemd as `openvibe-bot.service` (unit reference: `deploy/systemd/openvibe-bot.service`), with `WorkingDirectory=/opt/openvibe.bot`, `ExecStart` running `server/index.js`, and secrets/config read from the environment file `/etc/openvibe/bot.env`. The app listens on port **4630** behind nginx (`deploy/nginx/openvibe.bot.conf`), which terminates TLS and upgrades the `/device` and `/control` WebSockets to `127.0.0.1:4630`. Readiness is `http://127.0.0.1:4630/api/ready` (liveness: `/api/health`); both are loopback-only.++Production runs the Node process under systemd as `openvibe-bot.service` (unit reference: `deploy/systemd/openvibe-bot.service`), with `WorkingDirectory=/opt/openvibe.bot`, `ExecStart` running `server/index.js`, and secrets/config read from the environment file `/etc/openvibe/bot.env`. The app listens on port **4630** behind nginx (`deploy/nginx/openvibe.bot.conf`), which terminates TLS and upgrades the `/device`, `/control` and `/watch` WebSockets to `127.0.0.1:4630`. Readiness is `http://127.0.0.1:4630/api/ready` (liveness: `/api/health`); both are loopback-only.To run the schema migrations before the service serves, start it once with the direct owner connection in `/etc/openvibe/bot.env` (`DATABASE_DIRECT_URL`); the boot applies `migrations/` and then serves on the pooled `DATABASE_URL`.@@@ -65,6 +65,8 @@ Set `BOT_PAIRING_AUTHORITY` to `bot` (the default) or `network`; any other valueSet `BOT_EMBED_ORIGINS` (comma or space separated; default `https://openvibe.live,https://www.openvibe.live`) to the pages that may frame a robot's read-only panel (the CSP `frame-ancestors` list). Each entry is a bare https origin, with no path, query or wildcard (`http://localhost:<port>` only outside production); an invalid entry refuses to boot. A robot is embeddable only after its owner turns on `embed_public` with the form on its panel (`POST /robots/:id/embed`); the flag is web-only and never appears in `/api/v1`. Migration `0005_embed_public.sql` adds the column, off for every robot.++The embeddable panel is `GET /panel/:id/embed` (framed only by `BOT_EMBED_ORIGINS`; no cookie needed to watch) and its read-only socket is `wss://openvibe.bot/watch` (docs/protocol.md §2.1); nginx must upgrade `/watch` like `/control`. Set `BOT_WATCH_MAX_PER_IP` (default 20) and `BOT_WATCH_MAX_PER_ROBOT` (default 500) to cap open `/watch` sockets per client address and watchers per robot; over a cap the socket closes 4003.++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_OPENRE_TOKEN` (a Network service token holding `openre.stream.read`, `openre.stream.write` and `openre.key.rotate`, or `openre.stream.*` with `openre.key.rotate`; `BOT_OPENRE_TIMEOUT_MS`, default 8000, bounds each call). 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 either variable unset, devices still pair but get no publish key and the answer says `"video": "not_configured"`; Bot mints no key of its own.@@@ -39,7 +39,8 @@"signed-in pages: /robots (list and add), /pair/:id (the code and the one-paste installer command) and /panel/:id, rendered from the robot's profile and driven over /control; a sim.rover robot is driven by an in-process simulated device","panel input: a touch joystick, the keyboard and a gamepad (Gamepad API), each held control re-sent while held and zeroed at once on release; a latency meter (round-trip median and telemetry age); one camera tile per camera the profile lists (a placeholder until OpenRe can play a WHIP stream back); a live online indicator on /pair/:id read from /control","platform.job@1 jobs dispatched to a paired Node over /device (platform.job-frame@1: resent unacked on reconnect, only to an advertised runtime class) and metered per second into run_usage_outbox for billing.usage.record",-- "embed opt-in: robots.embed_public (migration 0005), set only by the owner with POST /robots/:id/embed, web-only (not in /api/v1); BOT_EMBED_ORIGINS is the validated frame-ancestors allow-list for the embeddable panel (default OpenVibe.Live), the embed view itself follows in a later step"++ "embed opt-in: robots.embed_public (migration 0005), set only by the owner with POST /robots/:id/embed, web-only (not in /api/v1); BOT_EMBED_ORIGINS is the validated frame-ancestors allow-list for the embeddable panel (default OpenVibe.Live)",++ "embeddable panel: GET /panel/:id/embed framed only by BOT_EMBED_ORIGINS (no cookie, never a sign-in redirect; a member keeps their role, anyone else watches an embed_public robot) and the anonymous read-only /watch socket carrying the public state (online, e-stop, latency, battery, the profile's readouts), capped per address and per robot"],"updated": "2026-10-04"}@@@ -3,8 +3,8 @@#### Deployed to /etc/nginx/sites-available/openvibe.bot.conf — keep them in sync and reload nginx## after changing it. The Node app (port 4630, systemd unit openvibe-bot) serves the REST API and--## terminates the device (`/device`) and operator (`/control`) WebSockets; nginx terminates TLS,--## redirects www → apex, upgrades the two WebSocket routes, rate-limits sign-in and the API, and++## terminates the device (`/device`), operator (`/control`) and watch (`/watch`) WebSockets; nginx terminates TLS,++## redirects www → apex, upgrades the three WebSocket routes, rate-limits sign-in and the API, and## serves the existing OpenVibe.Sites public page and shared assets.#### No proxy_cache: the app decides cacheability per response (no-store on the API), and device and@@@ -105,6 +105,20 @@ server {proxy_send_timeout 3600s;}++ # ── Watch WebSocket: anonymous and read-only, for the embeddable panel (plan T15 R9) ──++ # No credential; the app caps sockets per address (BOT_WATCH_MAX_PER_IP) and per robot, and the++ # upgrade itself is rate-limited here like any anonymous request.++ location = /watch {++ limit_req zone=ovbot_api burst=20 nodelay;++ limit_req_status 429;++ proxy_pass http://127.0.0.1:4630;++ proxy_set_header Upgrade $http_upgrade;++ proxy_set_header Connection $connection_upgrade;++ proxy_buffering off;++ proxy_read_timeout 3600s;++ proxy_send_timeout 3600s;++ }++# ── Network SSO (sign-in, callback, sign-out) ───────────location /auth/ {limit_req zone=ovbot_auth burst=10 nodelay;@@@ -1,11 +1,13 @@# OpenVibe.Bot — the device and control protocolEverything here is exactly what `server/realtime.js` implements today; the device agent job builds--against this document. Two WebSockets share one hub:++against this document. Three WebSockets share one hub:- `wss://openvibe.bot/device` — one **outbound** connection per device (ADR-043 decision 4). It worksbehind any home router and on an ESP32; video goes separately over WHIP to OpenRe.- `wss://openvibe.bot/control` — operators (a signed-in person, or a service acting for one).++- `wss://openvibe.bot/watch` — anyone, read-only: the public state of a robot whose owner allows embedding++ (§2.1, the embeddable panel).Every frame is JSON with three envelope fields on **every** message:@@@ -365,6 +367,43 @@ Refusal codes (the gate, in order): `bot.robot_not_found`, `bot.unknown_command``halt` is never refused for the e-stop, the allowlist, a cooldown or the turn budget; it still needs arole that may drive (a queue robot's turn holder, not someone waiting) and an online device.++### 2.1 `/watch` and the embeddable panel (plan T15 R9)++++`GET /panel/:id/embed` is the panel for a frame on another site. It never redirects to sign in and sets no++cookie. A signed-in visitor (the `ov_token` cookie) who is a member gets their role exactly as `/panel/:id`++decides (a stranger on a `queue` robot: `queue`) and drives over `/control`; anyone else gets the role++`watcher` when the owner turned on `embed_public` (`POST /robots/:id/embed`), with every control disabled++and a "Sign in to control" link that opens `/panel/:id` in a new tab; otherwise `403` with only a link out.++An unknown robot is `404`. The e-stop state is always shown; the E-stop button only to an owner or operator.++Its response carries `Content-Security-Policy: default-src 'self'; frame-ancestors 'self' <BOT_EMBED_ORIGINS>;++object-src 'none'; base-uri 'self'` and `Cache-Control: no-store`; every other page keeps++`frame-ancestors 'self'`.++++The watcher's panel joins on `wss://openvibe.bot/watch`: no credential, never a cookie or token.++++| client → server | fields | answer |++|---|---|---|++| `join` | `robot_id` | `joined { role: "watcher", profile, allowed_commands: [], state }` for an `embed_public` robot; otherwise (or an unknown robot) `error` `bot.not_an_operator` |++| `leave` | — | unsubscribe |++| anything else (`command`, `estop`, `estop_clear`, …) | — | `error` `bot.read_only`; never reaches a device or the audit |++++A joined watcher then gets `robot_state { state }` on every change, as a `/control` subscriber does, but always++the **public state**: `{ robot_id, online, estop{latched}, latency_ms, battery, telemetry }`, where++`telemetry` is `null` or `{ sensors: { … } }` holding only the keys the profile's `telemetry` widgets read++(`sensor.ultrasonic` → `ultrasonic`). Never the queue, a subject, who latched the e-stop, the device's ids,++its `status` or the rest of its telemetry.++++Frames over 4 KiB close the socket (1009). Over `BOT_WATCH_MAX_PER_IP` open sockets from one client address++(default 20) or `BOT_WATCH_MAX_PER_ROBOT` watchers on one robot (default 500) the socket closes **4003**; the++owner turning `embed_public` off closes the robot's watchers with 4003 too.++++```json++{ "v": 1, "seq": 1, "ts": 1738065600001, "type": "joined", "role": "watcher", "profile": { "id": "adeept.adr036" },++ "allowed_commands": [],++ "state": { "robot_id": "rob_01J8Z4M2Q0R7T9YV3K6N8P1W2X", "online": true, "estop": { "latched": false },++ "latency_ms": 63, "battery": 0.72, "telemetry": { "sensors": { "ultrasonic": 41.5 } } } }++```++---## 3. REST (the same gate, HTTP)@@@ -18,13 +18,16 @@* 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*++ * 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.++ ** Also on the pairing page: [data-copy] copies the installer command, and [data-pair-robot] joins the robot on* /control (the owner's own, read-only use of it) to flip "Waiting for the device." when the device connects.*/(() => {const $ = (sel, root = document) => root.querySelector(sel);const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];-- const wsUrl = () => `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/control`;++ const wsUrl = (path = '/control') => `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}${path}`;for (const b of $$('[data-copy]')) {b.addEventListener('click', () => {@@@ -69,6 +72,7 @@const main = document.getElementById('panel');if (!main) return;const ROBOT_ID = main.dataset.robotId;++ const WATCHER = main.dataset.role === 'watcher';const HOLD_MS = Number(main.dataset.holdMs) || 150;const MIN_GAP_MS = 40; // a moving stick sends at most this often between the hold re-sendsconst STICK_DEADZONE = 0.12; // touch joystick, fraction of its radius@@@ -92,6 +96,7 @@}function raw(frame) {if (!ws || ws.readyState !== WebSocket.OPEN) return false;++ if (WATCHER && frame.type !== 'join') return false; // /watch is read-onlyws.send(JSON.stringify({ v: 1, ...frame }));return true;}@@@ -491,7 +496,7 @@}function connect() {-- ws = new WebSocket(wsUrl());++ ws = new WebSocket(wsUrl(WATCHER ? '/watch' : '/control'));ws.onopen = () => raw({ type: 'join', robot_id: ROBOT_ID });ws.onmessage = (e) => { let m; try { m = JSON.parse(e.data); } catch { return; } onFrame(m); };ws.onclose = (e) => {@@@ -184,6 +184,11 @@ function loadConfig(env = process.env) {// chooses the target.// Embeddable panel (plan T15 R9): the origins whose pages may frame a robot's read-only panel.embed: { origins: embedOrigins },++ // The anonymous read-only /watch socket: open sockets per client address and watchers per robot; over → close 4003.++ watch: {++ maxPerIp: Math.max(1, int(env.BOT_WATCH_MAX_PER_IP, 20)),++ maxPerRobot: Math.max(1, int(env.BOT_WATCH_MAX_PER_ROBOT, 500)),++ },installer: {scriptUrl: trim(env.BOT_INSTALLER_URL || 'https://openvibe.bot/install'),sourceUrl: installerSource,@@@ -13,6 +13,10 @@* wss://…/control a signed-in person (Network session cookie or Bearer) or a service token holding* `bot.robot.control` acting for `X-OV-Subject`. Joins a robot, sends commands; the* gate lives in the domain and every decision is audited.++ * wss://…/watch anyone, no credential (plan T15 R9): joins a robot whose owner turned on embed_public and++ * receives its public state only (robotState → publicState). Read-only: any frame but++ * join/leave answers bot.read_only and never reaches a device or the audit. Capped per client++ * address and per robot (config.watch); over a cap the socket closes 4003.** Every frame carries v, seq, ts. A command's `id` is an idempotency key: a repeated id answers with the* first result instead of reaching the device again.@@@ -29,6 +33,7 @@ const MAX_BACKLOG = 64; // frames a device may send before its authenticationconst ACCESS_COOKIE = 'ov_token';const DEVICE_KINDS = new Set(['onboard', 'bridge', 'server']);const MAX_AGENT_VERSION = 40;++const WATCH_MAX_PAYLOAD = 4 * 1024; // a watcher only ever sends join/leavefunction parseCookies(header) {const out = {};@@@ -53,6 +58,9 @@ function createRealtime({ config, keys, userAuth, usage = null, log = console, nconst deviceWss = new WebSocketServer({ noServer: true, maxPayload: 64 * 1024, perMessageDeflate: false });const controlWss = new WebSocketServer({ noServer: true, maxPayload: 64 * 1024, perMessageDeflate: false });++ const watchWss = new WebSocketServer({ noServer: true, maxPayload: WATCH_MAX_PAYLOAD, perMessageDeflate: false });++ const watchConns = new Set(); // every /watch socket, joined or not++ const watchersByAddr = new Map(); // client address → open /watch socketsconst offlineAfterMs = config.device.heartbeatMs * config.device.offlineMisses + config.device.offlineGraceMs;const jobs = createJobFrames({ db: () => domain.db, usage, log, now });@@@ -77,6 +85,7 @@ function createRealtime({ config, keys, userAuth, usage = null, log = console, ntry { path = new URL(req.url, 'http://localhost').pathname; } catch { return socket.destroy(); }if (path === '/device') return deviceWss.handleUpgrade(req, socket, head, (ws) => onDeviceSocket(ws, req));if (path === '/control') return controlWss.handleUpgrade(req, socket, head, (ws) => onControlSocket(ws, req));++ if (path === '/watch') return watchWss.handleUpgrade(req, socket, head, (ws) => onWatchSocket(ws, req));socket.destroy();}@@@ -487,6 +496,68 @@ function createRealtime({ config, keys, userAuth, usage = null, log = console, nbroadcast(robotId);}++ // ── Watcher side (read-only, anonymous) ───────────────────────────────────────────────────────────++ /** The caller's address as Express decides it with the same trust proxy hop count (config.trustProxy). */++ function clientAddress(req) {++ const forwarded = String(req.headers['x-forwarded-for'] || '').split(',').map((s) => s.trim()).filter(Boolean).reverse();++ const chain = [req.socket.remoteAddress || '', ...forwarded];++ const hops = Number.isInteger(config.trustProxy) && config.trustProxy > 0 ? config.trustProxy : 0;++ return chain[Math.min(hops, chain.length - 1)];++ }++++ function onWatchSocket(ws, req) {++ const addr = clientAddress(req);++ const conn = { ws, seq: 0, watcher: true, subject: null, robotId: null, role: 'watcher', readouts: [], addr };++ ws.conn = conn;++ watchConns.add(conn);++ watchersByAddr.set(addr, (watchersByAddr.get(addr) || 0) + 1);++ ws.on('close', () => {++ unsubscribe(conn);++ watchConns.delete(conn);++ const n = (watchersByAddr.get(addr) || 1) - 1;++ if (n > 0) watchersByAddr.set(addr, n); else watchersByAddr.delete(addr);++ });++ ws.on('error', () => { /* the close event does the work */ });++ if (watchersByAddr.get(addr) > config.watch.maxPerIp) return ws.close(4003, 'too many watchers from this address');++ ws.on('message', (raw) => handleWatchMessage(conn, raw).catch((e) => { log.warn(`[Bot] watch: ${e.message}`); sendError(conn, 'bot.internal', null); }));++ return undefined;++ }++++ async function handleWatchMessage(conn, raw) {++ let msg; try { msg = JSON.parse(String(raw)); } catch { return sendError(conn, 'bot.bad_json', 'frames must be JSON'); }++ if (!msg || typeof msg !== 'object') return sendError(conn, 'bot.bad_frame', 'a frame must be an object');++ if (msg.type === 'join') return onWatchJoin(conn, msg);++ if (msg.type === 'leave') { unsubscribe(conn); conn.robotId = null; return undefined; }++ return sendError(conn, 'bot.read_only', 'this socket only watches; sign in on openvibe.bot to control');++ }++++ async function onWatchJoin(conn, msg) {++ const robotId = String(msg.robot_id || '').slice(0, 64);++ const robot = robotId ? await domain.robots.get(robotId) : null;++ // An unknown robot and a robot that is not public answer alike: an anonymous caller learns nothing.++ if (!robot || !robot.embed_public) return sendError(conn, 'bot.not_an_operator', 'this robot is not public');++ if (conn.robotId !== robotId) {++ const set = subsByRobot.get(robotId);++ let watching = 0;++ if (set) for (const c of set) if (c.watcher) watching++;++ if (watching >= config.watch.maxPerRobot) return conn.ws.close(4003, 'too many watchers on this robot');++ if (conn.robotId) unsubscribe(conn);++ }++ const profileRow = await getProfile(domain.db, robot.profile_id, robot.profile_version);++ const profile = profileRow ? profileRow.profile : null;++ conn.robotId = robotId;++ conn.readouts = readoutKeys(profile);++ subscribe(conn);++ return sendFrame(conn, 'joined', { role: 'watcher', profile, allowed_commands: [], state: publicState(await robotState(robotId), conn.readouts) });++ }++++ /** The owner turned embed_public off: every watcher of the robot is dropped at once. */++ function closeWatchers(robotId, reason = 'this robot is no longer public') {++ const set = subsByRobot.get(robotId);++ if (!set) return;++ for (const c of [...set]) if (c.watcher) { unsubscribe(c); try { c.ws.close(4003, reason); } catch { /* gone */ } }++ }++// ── State and helpers ─────────────────────────────────────────────────────────────────────────function deviceForRobot(robotId) {let sim = null;@@@ -538,10 +609,40 @@ function createRealtime({ config, keys, userAuth, usage = null, log = console, nif (robot && robot.access_policy === 'queue') state.queue = await domain.queue.state(robotId, subjectForQueue);return state;}++ /** The telemetry keys a profile's readout widgets show (`telemetry` widgets, `sensor.<key>` capabilities). */++ function readoutKeys(profile) {++ const keys = new Set();++ for (const w of (profile && profile.widgets) || []) {++ if (w && w.type === 'telemetry' && typeof w.capability === 'string' && w.capability) keys.add(w.capability.replace(/^sensor\./, ''));++ }++ return [...keys];++ }++ /**++ * What a watcher may see: online, the latch, latency, battery and the readouts' sensor values. Never the++ * queue, a subject, the device's ids, its status or the rest of its telemetry.++ */++ function publicState(state, readouts) {++ const t = state.telemetry;++ const sensors = t && t.sensors && typeof t.sensors === 'object' ? t.sensors : {};++ const picked = {};++ for (const k of readouts) if (Object.prototype.hasOwnProperty.call(sensors, k)) picked[k] = sensors[k];++ return {++ robot_id: state.robot_id, online: state.online,++ estop: { latched: !!(state.estop && state.estop.latched) },++ latency_ms: state.latency_ms, battery: state.battery,++ telemetry: t ? { sensors: picked } : null,++ };++ }function broadcast(robotId) {const subs = subsByRobot.get(robotId);if (!subs || !subs.size) return;++ let watched = null; // one state for every watcher (no subject, no queue view)for (const sub of [...subs]) {++ if (sub.watcher) {++ watched = watched || robotState(robotId);++ watched.then((state) => sendFrame(sub, 'robot_state', { state: publicState(state, sub.readouts) })).catch(() => {});++ continue;++ }robotState(robotId, sub.subject).then((state) => sendFrame(sub, 'robot_state', { state })).catch(() => {});}}@@@ -568,7 +669,7 @@ function createRealtime({ config, keys, userAuth, usage = null, log = console, nreturn {bindDomain(d) { domain = d; },handleUpgrade, attachSim, detachSim,-- isOnline, deviceState, sendToDevice, sendToRobotDevices, broadcast, closeDevice, robotState, refreshConfig, pushEstop,++ isOnline, deviceState, sendToDevice, sendToRobotDevices, broadcast, closeDevice, robotState, refreshConfig, pushEstop, closeWatchers,jobStdout: (jobId) => jobs.stdout(jobId),// Real devices only: a simulator is not a machine anyone runs.onlineCount() { let n = 0; for (const c of deviceConns.values()) if (c.online && !c.sim) n++; return n; },@@@ -581,9 +682,10 @@ function createRealtime({ config, keys, userAuth, usage = null, log = console, ninflight.clear();for (const conn of [...deviceConns.values()]) { try { conn.ws.close(1001, 'server closing'); } catch { /* gone */ } }for (const set of subsByRobot.values()) for (const conn of set) { try { conn.ws.close(1001, 'server closing'); } catch { /* gone */ } }-- deviceConns.clear(); subsByRobot.clear();++ for (const conn of watchConns) { try { conn.ws.close(1001, 'server closing'); } catch { /* gone */ } }++ deviceConns.clear(); subsByRobot.clear(); watchConns.clear(); watchersByAddr.clear();await Promise.all([-- new Promise((r) => deviceWss.close(r)), new Promise((r) => controlWss.close(r)),++ new Promise((r) => deviceWss.close(r)), new Promise((r) => controlWss.close(r)), new Promise((r) => watchWss.close(r)),]).catch(() => {});},};@@@ -7,7 +7,9 @@* 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 })++ * renderPanel({ robot, profile, role, allowed_commands, holdResendMs, mode, signedIn })++ * mode 'embed': the framed panel (no topbar, no owner form, links open a new tab)++ * renderEmbedRefused({ robotId }) the framed answer when the visitor may not see the robot* renderWidget(widget, { profile, allowed_commands })* renderRobotsPage({ robots, profiles, error, values })* renderPairingPage({ robot, pairing, profile })@@@ -26,7 +28,7 @@ const STICK_X = ['steer', 'x', 'rotation'];const STICK_Y = ['throttle', 'y'];const POLICIES = [['private', 'Only me and people I add'], ['invite', 'People I invite'], ['queue', 'Anyone, taking turns']];const POLICY_LABEL = Object.fromEntries(POLICIES.map(([v]) => [v, { private: 'Private', invite: 'Invite only', queue: 'Open queue' }[v]]));--const ROLE_LABEL = { owner: 'Owner', operator: 'Operator', viewer: 'Viewer', queue: 'In the queue' };++const ROLE_LABEL = { owner: 'Owner', operator: 'Operator', viewer: 'Viewer', queue: 'In the queue', watcher: 'Watching' };// ── Icons: 24×24 strokes in currentColor, decorative (the text beside them is the label) ─────────────────────const PATHS = {@@@ -218,7 +220,11 @@ function embedForm(robot) {</form>`;}--function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs = 150 }) {++/** A link out of a frame: always a new tab, never with a handle back to the framing page. */++const outLink = (href, text, cls = 'button') => `<a class="${cls}" href="${esc(href)}" target="_blank" rel="noopener">${esc(text)}</a>`;++++function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs = 150, mode = 'page', signedIn = true }) {++ 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))@@@ -226,7 +232,7 @@ function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMsconst 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}">--${topbar(`<span class="crumb">${esc(robot.name)}</span>`)}++${embed ? '' : topbar(`<span class="crumb">${esc(robot.name)}</span>`)}<div class="estop-bar"><p class="estop-state" role="status" aria-live="assertive"><span class="estop-dot"></span><span data-estop-state>${latched ? 'E-stop latched' : 'E-stop clear'}</span><span class="estop-note">${latched ? ' — the robot stays still until the owner clears it.' : ''}</span></p>${clear}${estop}</div></header><main id="panel" data-robot-id="${esc(robot.id)}" data-role="${esc(role)}" data-hold-ms="${esc(holdResendMs)}" data-allowed="${data(allowed_commands)}">@@@ -234,13 +240,23 @@ ${topbar(`<span class="crumb">${esc(robot.name)}</span>`)}<h1>${esc(robot.name)}</h1><p class="meta"><span class="pill online" data-online-pill data-state="connecting"><span class="dot"></span><span data-online-label>Connecting…</span></span> <span class="pill">${esc(profile.name || robot.profile_id)}</span> <span class="pill role" data-role-label>${esc(ROLE_LABEL[role] || role)}</span></p></div>++${embed && role === 'watcher' && !signedIn ? `<p class="embed-sign-in">${outLink(`/panel/${robot.id}`, 'Sign in to control')}</p>` : ''}<p class="notice" data-notice role="alert" hidden></p><div class="widgets">${widgets.join('\n')}</div>--${role === 'owner' ? embedForm(robot) : ''}++${role === 'owner' && !embed ? embedForm(robot) : ''}++</main>`;++ return page(robot.name, body, { scripts: ['/panel/panel.js'], bodyClass: embed ? 'embed-page' : 'panel-page' });++}++++/** The framed 403: no robot data, only a way out to openvibe.bot (where signing in may grant access). */++function renderEmbedRefused({ robotId }) {++ const body = `<main class="page embed-refused">++<div class="card"><p>This robot is not shared for embedding.</p>++<p>${outLink(`/panel/${robotId}`, 'Open on openvibe.bot', 'button primary')}</p></div></main>`;-- return page(robot.name, body, { scripts: ['/panel/panel.js'], bodyClass: 'panel-page' });++ return page('Not shared', body, { bodyClass: 'embed-page' });}function renderRobotsPage({ robots = [], profiles = [], error = null, values = {} }) {@@@ -280,4 +296,4 @@ function renderPairingPage({ robot, pairing, profile = null }) {return page(`Pair ${robot.name}`, body, { scripts: ['/panel/panel.js'] });}--module.exports = { renderPanel, renderWidget, renderRobotsPage, renderPairingPage, camerasOf, esc };++module.exports = { renderPanel, renderEmbedRefused, renderWidget, renderRobotsPage, renderPairingPage, camerasOf, esc };@@@ -10,6 +10,10 @@* GET /pair/:id the owner only: a fresh pairing code and the installer command (never minted for a* cross-site navigation or a prefetch: those go to /robots)* GET /panel/:id a member (or anyone on a `queue` robot): the panel, rendered from the profile++ * GET /panel/:id/embed the panel for a frame (plan T15 R9): a signed-in member's role as /panel/:id decides it,++ * otherwise `watcher` (video and readouts, no control) when the owner allows embedding,++ * otherwise 403 with a link out. Never redirects to sign in; framed only by the++ * BOT_EMBED_ORIGINS allow-list (CSP frame-ancestors), every other page by itself* POST /robots/:id/embed the owner only: allow (embed_public=on) or stop (off) anonymous read-only embedding → 303 to its panel* GET /panel/panel.js, /panel/panel.css the panel client and its sheet (public/, no build step)*@@@ -20,8 +24,9 @@const path = require('path');const express = require('express');const { getProfile, listProfiles } = require('../profiles');++const { frameAncestors } = require('../config');const { BotError } = require('../util');--const { renderPanel, renderRobotsPage, renderPairingPage } = require('./render');++const { renderPanel, renderEmbedRefused, renderRobotsPage, renderPairingPage } = require('./render');const VERSION = require('../../package.json').version;const PUBLIC = path.join(__dirname, '..', '..', 'public');@@@ -125,27 +130,48 @@ function createWebRoutes(config, { domain = null, sim = null, limits = null, logconst value = Array.isArray(sent) ? sent[sent.length - 1] : sent;if (value !== 'on' && value !== 'off') throw new BotError(422, 'bot.invalid_input', 'embed_public must be on or off');await domain.robots.setEmbedPublic(robot.id, value === 'on');++ if (value === 'off' && domain.link && domain.link.closeWatchers) domain.link.closeWatchers(robot.id);res.redirect(303, `/panel/${robot.id}`);}));++ /** As the /control join decides: a member's role, or a place in the queue on a `queue` robot; else null. */++ async function memberRole(robot, me) {++ const role = await domain.members.roleOf(robot.id, me.subject);++ return role || (robot.access_policy === 'queue' ? 'queue' : null);++ }++ async function sendPanel(res, robot, profile, role, mode = 'page', signedIn = true) {++ await startSim(robot, profile);++ const { maxCommandMs } = domain.control.effectiveLimits(robot, profile);++ res.type('html').send(renderPanel({++ robot: presentForPage(robot), profile, role, mode, signedIn,++ 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))),++ }));++ }++r.get('/panel/:id', page(async (req, res) => {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');-- // As the /control join decides: a member's role, or a place in the queue on a `queue` robot.-- let role = await domain.members.roleOf(robot.id, me.subject);-- if (!role && robot.access_policy === 'queue') role = 'queue';++ const role = await memberRole(robot, me);if (!role) throw new BotError(403, 'bot.not_an_operator', 'you have no access to this robot');const profile = await profileOf(robot);if (!profile) throw new BotError(404, 'bot.profile_not_found', 'this robot has no profile');-- await startSim(robot, profile);-- const { maxCommandMs } = domain.control.effectiveLimits(robot, profile);-- res.type('html').send(renderPanel({-- robot: presentForPage(robot), profile, role,-- allowed_commands: 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))),-- }));++ await sendPanel(res, robot, profile, role);++ }));++++ // 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('; '));++ 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;++ if (!role && robot.embed_public) role = 'watcher';++ if (!role) return res.status(403).type('html').send(renderEmbedRefused({ robotId: robot.id }));++ const profile = await profileOf(robot);++ if (!profile) throw new BotError(404, 'bot.profile_not_found', 'this robot has no profile');++ return sendPanel(res, robot, profile, role, 'embed', !!req.viewer);}));return r;nnew file mode 100644@@@ -0,0 +1,202 @@++'use strict';++// The embeddable panel (plan T15 step R9, s2): GET /panel/:id/embed framed only by BOT_EMBED_ORIGINS, and the++// anonymous read-only /watch socket that carries an embed_public robot's public state and nothing else.++const fs = require('fs');++const path = require('path');++const assert = require('assert');++const { boot, check, done } = require('./helpers/app');++const { loadConfig } = require('../server/config');++const { renderPanel } = require('../server/web/render');++++(async () => {++ const SIM = { id: 'sim.rover', name: 'Simulated rover', commands: { drive: { axes: { throttle: [-1, 1], steer: [-1, 1] } } }, widgets: [{ type: 'drive', label: 'Drive', command: { kind: 'drive' } }, { type: 'latency', label: 'Latency' }] };++ const robotRow = { id: 'rob_e1', name: 'Rover', profile_id: 'sim.rover', estop: { latched: false } };++++ await check('BOT_WATCH_MAX_PER_IP and BOT_WATCH_MAX_PER_ROBOT default to 20 and 500', () => {++ assert.deepStrictEqual(loadConfig({}).watch, { maxPerIp: 20, maxPerRobot: 500 });++ assert.deepStrictEqual(loadConfig({ BOT_WATCH_MAX_PER_IP: '3', BOT_WATCH_MAX_PER_ROBOT: '0' }).watch, { maxPerIp: 3, maxPerRobot: 1 });++ });++++ await check('embed mode: no topbar, the e-stop state always, controls only for the role, links open a new tab', () => {++ const watcher = renderPanel({ robot: robotRow, profile: SIM, role: 'watcher', allowed_commands: [], mode: 'embed', signedIn: false });++ assert.ok(watcher.includes('class="embed-page"') && !watcher.includes('class="topbar"'));++ assert.ok(watcher.includes('data-estop-banner') && watcher.includes('data-estop-state'));++ assert.ok(!watcher.includes('data-estop>') && !watcher.includes('data-estop-clear') && !watcher.includes('data-embed-form'));++ assert.ok(watcher.includes('data-role="watcher"') && watcher.includes('data-allowed="[]"'));++ assert.ok(!/<button(?![^>]*disabled)[^>]*data-(hold|stop)/.test(watcher), 'a control is enabled for a watcher');++ assert.match(watcher, /<a class="button" href="\/panel\/rob_e1" target="_blank" rel="noopener">Sign in to control<\/a>/);++ for (const a of watcher.match(/<a [^>]*>/g)) assert.ok(a.includes('target="_blank"') && a.includes('rel="noopener"'), a);++ const signedIn = renderPanel({ robot: robotRow, profile: SIM, role: 'watcher', allowed_commands: [], mode: 'embed', signedIn: true });++ assert.ok(!signedIn.includes('Sign in to control'));++ const owner = renderPanel({ robot: { ...robotRow, embed_public: true }, profile: SIM, role: 'owner', allowed_commands: ['drive', 'halt'], mode: 'embed' });++ assert.ok(owner.includes('data-estop>') && owner.includes('data-estop-clear') && !owner.includes('data-embed-form') && !owner.includes('<a '));++ const page = renderPanel({ robot: robotRow, profile: SIM, role: 'owner', allowed_commands: ['drive'] });++ 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', () => {++ 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;/);++ });++++ const t = await boot({ env: { BOT_WATCH_MAX_PER_IP: '2', BOT_WATCH_MAX_PER_ROBOT: '3' } });++ const alex = t.network.newUser('alex');++ const bob = t.network.newUser('bob');++ const cookie = (user) => ({ Cookie: `ov_token=${t.network.signUser(user)}` });++ const get = (p, user) => fetch(t.base + p, { redirect: 'manual', headers: user ? cookie(user) : {} });++ 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 auditCount = async (id) => (await t.domain.audit.list(id, { limit: 100 })).length;++++ const { robot: open } = await t.robot(alex, { name: 'Public rover' });++ await t.domain.robots.setEmbedPublic(open.id, true);++ const { robot: closed } = await t.robot(alex, { name: 'Private rover' });++++ await check('GET /panel/:id/embed: a public robot to anyone, with the embed CSP, no cookie and never a redirect', async () => {++ const r = await get(`/panel/${open.id}/embed`);++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.headers.get('content-security-policy'), EMBED_CSP);++ assert.strictEqual(r.headers.get('cache-control'), 'no-store');++ assert.strictEqual(r.headers.get('set-cookie'), null);++ const html = await r.text();++ assert.ok(html.includes('data-role="watcher"') && html.includes('data-allowed="[]"') && html.includes('class="embed-page"'));++ assert.ok(html.includes('Sign in to control') && !html.includes('data-estop>'));++ assert.ok(t.hub.isOnline(`dev_sim_${open.id}`), 'the embed starts the simulator as the panel does');++ // The rest of the site still refuses every frame.++ assert.match((await get(`/panel/${open.id}`, alex)).headers.get('content-security-policy'), /frame-ancestors 'self';/);++ assert.match((await get('/robots', alex)).headers.get('content-security-policy'), /frame-ancestors 'self';/);++ });++++ await check('a robot that is not public: 403 with a link out and no robot data; unknown: 404; members keep their role', async () => {++ const r = await get(`/panel/${closed.id}/embed`);++ assert.strictEqual(r.status, 403);++ assert.strictEqual(r.headers.get('content-security-policy'), EMBED_CSP);++ assert.strictEqual(r.headers.get('set-cookie'), null);++ const html = await r.text();++ assert.ok(!html.includes('Private rover') && !html.includes('data-robot-id'));++ assert.match(html, /href="\/panel\/rob_[0-9A-Za-z]+" target="_blank" rel="noopener">Open on openvibe\.bot</);++ assert.strictEqual((await get(`/panel/${closed.id}/embed`, bob)).status, 403);++ assert.strictEqual((await get('/panel/rob_nothing/embed')).status, 404);++ const owner = await get(`/panel/${closed.id}/embed`, alex);++ assert.strictEqual(owner.status, 200);++ const oh = await owner.text();++ assert.ok(oh.includes('data-role="owner"') && oh.includes('data-estop>') && !oh.includes('data-embed-form') && !oh.includes('Sign in to control'));++ const stranger = await (await get(`/panel/${open.id}/embed`, bob)).text();++ assert.ok(stranger.includes('data-role="watcher"') && !stranger.includes('Sign in to control'));++ });++++ 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 });++ assert.strictEqual((await w.waitFor((m) => m.type === 'error')).code, 'bot.not_an_operator');++ w.send({ type: 'join', robot_id: 'rob_nothing' });++ await w.waitFor((m) => m.type === 'error' && w.messages.filter((x) => x.type === 'error').length === 2);++ assert.strictEqual(w.messages[1].code, 'bot.not_an_operator');++ w.send({ type: 'join', robot_id: open.id });++ const j = await w.waitFor((m) => m.type === 'joined');++ assert.strictEqual(j.role, 'watcher');++ assert.deepStrictEqual(j.allowed_commands, []);++ assert.strictEqual(j.profile.id, 'sim.rover');++ assert.strictEqual(j.robot, undefined);++ assert.deepStrictEqual(Object.keys(j.state).sort(), ['battery', 'estop', 'latency_ms', 'online', 'robot_id', 'telemetry']);++ assert.deepStrictEqual(Object.keys(j.state.estop), ['latched']);++ const s = await w.waitFor((m) => m.type === 'robot_state' && m.state.telemetry);++ assert.deepStrictEqual(s.state.telemetry, { sensors: {} }, 'the drive and pose telemetry reached a watcher');++ assert.ok(s.state.online && s.state.battery > 0);++ w.close();++ });++++ await check('every frame but join/leave answers bot.read_only and reaches neither the device nor the audit', async () => {++ const w = await watch();++ w.send({ type: 'join', robot_id: open.id });++ await w.waitFor((m) => m.type === 'joined');++ const before = await auditCount(open.id);++ for (const f of [{ type: 'command', id: 'x1', kind: 'drive', value: { throttle: 1 } }, { type: 'command', kind: 'halt' }, { type: 'estop' }, { type: 'estop_clear' }, { type: 'pair' }]) w.send(f);++ await w.waitFor(() => w.messages.filter((m) => m.type === 'error').length >= 5);++ assert.deepStrictEqual(w.messages.filter((m) => m.type === 'error').map((m) => m.code), Array(5).fill('bot.read_only'));++ assert.ok(!w.messages.some((m) => m.type === 'command_result'));++ await t.wait(100);++ assert.strictEqual(await auditCount(open.id), before);++ assert.strictEqual((await t.domain.robots.get(open.id)).estop_latched, false);++ const tele = (m) => m.type === 'robot_state' && m.state.telemetry;++ assert.ok(await w.waitFor(tele));++ assert.strictEqual(t.hub.deviceState(`dev_sim_${open.id}`).telemetry.drive.throttle || 0, 0, 'the robot moved');++ w.close();++ });++++ await check('the owner\'s e-stop reaches a watcher; readouts carry only the profile\'s sensor keys', async () => {++ const w = await watch();++ w.send({ type: 'join', robot_id: open.id });++ await w.waitFor((m) => m.type === 'joined');++ const c = await t.ws('/control', { headers: cookie(alex) });++ c.send({ type: 'join', robot_id: open.id });++ await c.waitFor((m) => m.type === 'joined');++ c.send({ type: 'estop' });++ assert.ok(await w.waitFor((m) => m.type === 'robot_state' && m.state.estop.latched === true));++ c.send({ type: 'estop_clear' });++ assert.ok(await w.waitFor((m) => m.type === 'robot_state' && m.state.estop.latched === false && w.messages.some((x) => x.state && x.state.estop.latched)));++ c.close(); w.close();++++ const { robot: arm } = await t.robot(alex, { name: 'Arm', profile_id: 'adeept.adr036' });++ await t.domain.robots.setEmbedPublic(arm.id, true);++ const dev = t.hub.attachSim({ id: 'dev_fake_arm', robot_ids: JSON.stringify([arm.id]), kind: 'onboard' }, { onFrame() {} });++ await dev.ready;++ const a = await watch();++ a.send({ type: 'join', robot_id: arm.id });++ await a.waitFor((m) => m.type === 'joined');++ dev.deliver({ type: 'telemetry', events: ['bump'], battery: 0.5, sensors: { ultrasonic: 42, secret: 7 }, pose: { x: 1 } });++ const s = await a.waitFor((m) => m.type === 'robot_state' && m.state.telemetry);++ assert.deepStrictEqual(s.state.telemetry, { sensors: { ultrasonic: 42 } });++ assert.strictEqual(s.state.battery, 0.5);++ a.close();++ t.hub.detachSim('dev_fake_arm');++ });++++ await check('turning embedding off closes every watcher of the robot with 4003', async () => {++ const { robot } = await t.robot(alex, { name: 'Briefly public' });++ await t.domain.robots.setEmbedPublic(robot.id, true);++ const w = await watch();++ w.send({ type: 'join', robot_id: robot.id });++ await w.waitFor((m) => m.type === 'joined');++ const r = await fetch(`${t.base}/robots/${robot.id}/embed`, { method: 'POST', redirect: 'manual', body: 'embed_public=off', headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...cookie(alex) } });++ assert.strictEqual(r.status, 303);++ assert.strictEqual(await w.waitForClose(), 4003);++ });++++ await check('caps: sockets per address and watchers per robot close 4003; an oversized frame closes the socket', async () => {++ const one = await watch('10.9.9.9');++ const two = await watch('10.9.9.9');++ const three = await watch('10.9.9.9');++ assert.strictEqual(await three.waitForClose(), 4003);++ one.close(); two.close();++ await one.waitForClose(); await two.waitForClose();++ const again = await watch('10.9.9.9');++ again.send({ type: 'join', robot_id: open.id });++ assert.ok(await again.waitFor((m) => m.type === 'joined'), 'the address was not released');++++ const { robot } = await t.robot(alex, { name: 'Crowded' });++ await t.domain.robots.setEmbedPublic(robot.id, true);++ const crowd = [];++ for (let i = 0; i < 3; i++) {++ const w = await watch();++ w.send({ type: 'join', robot_id: robot.id });++ await w.waitFor((m) => m.type === 'joined');++ crowd.push(w);++ }++ const late = await watch();++ late.send({ type: 'join', robot_id: robot.id });++ assert.strictEqual(await late.waitForClose(), 4003);++ crowd.forEach((w) => w.close());++++ again.ws.send('x'.repeat(5000));++ assert.strictEqual(await again.waitForClose(), 1009);++ });++++ t.app.locals.sim.stopAll();++ await t.close();++ done();++})();
@@ -93,3 +93,6 @@ OV_OAUTH_CLIENT_SECRET=
@@ -93,3 +93,6 @@ OV_OAUTH_CLIENT_SECRET=
# An invalid entry stops the service at boot. Default: OpenVibe.Live. A robot is embeddable only after its owner
# An invalid entry stops the service at boot. Default: OpenVibe.Live. A robot is embeddable only after its owner
# turns on "embed_public" on its panel.
# turns on "embed_public" on its panel.
#BOT_EMBED_ORIGINS=https://openvibe.live,https://www.openvibe.live
#BOT_EMBED_ORIGINS=https://openvibe.live,https://www.openvibe.live
+# The anonymous read-only /watch socket behind the embed: open sockets per client address and watchers per robot.
+#BOT_WATCH_MAX_PER_IP=20
+#BOT_WATCH_MAX_PER_ROBOT=500
@@ -57,7 +57,7 @@ A person acts on their own robots with a Network user token; a service acts with
@@ -57,7 +57,7 @@ A person acts on their own robots with a Network user token; a service acts with
## Deploy
## Deploy
-Production runs the Node process under systemd as `openvibe-bot.service` (unit reference: `deploy/systemd/openvibe-bot.service`), with `WorkingDirectory=/opt/openvibe.bot`, `ExecStart` running `server/index.js`, and secrets/config read from the environment file `/etc/openvibe/bot.env`. The app listens on port **4630** behind nginx (`deploy/nginx/openvibe.bot.conf`), which terminates TLS and upgrades the `/device` and `/control` WebSockets to `127.0.0.1:4630`. Readiness is `http://127.0.0.1:4630/api/ready` (liveness: `/api/health`); both are loopback-only.
+Production runs the Node process under systemd as `openvibe-bot.service` (unit reference: `deploy/systemd/openvibe-bot.service`), with `WorkingDirectory=/opt/openvibe.bot`, `ExecStart` running `server/index.js`, and secrets/config read from the environment file `/etc/openvibe/bot.env`. The app listens on port **4630** behind nginx (`deploy/nginx/openvibe.bot.conf`), which terminates TLS and upgrades the `/device`, `/control` and `/watch` WebSockets to `127.0.0.1:4630`. Readiness is `http://127.0.0.1:4630/api/ready` (liveness: `/api/health`); both are loopback-only.
To run the schema migrations before the service serves, start it once with the direct owner connection in `/etc/openvibe/bot.env` (`DATABASE_DIRECT_URL`); the boot applies `migrations/` and then serves on the pooled `DATABASE_URL`.
To run the schema migrations before the service serves, start it once with the direct owner connection in `/etc/openvibe/bot.env` (`DATABASE_DIRECT_URL`); the boot applies `migrations/` and then serves on the pooled `DATABASE_URL`.
@@ -65,6 +65,8 @@ Set `BOT_PAIRING_AUTHORITY` to `bot` (the default) or `network`; any other value
@@ -65,6 +65,8 @@ Set `BOT_PAIRING_AUTHORITY` to `bot` (the default) or `network`; any other value
Set `BOT_EMBED_ORIGINS` (comma or space separated; default `https://openvibe.live,https://www.openvibe.live`) to the pages that may frame a robot's read-only panel (the CSP `frame-ancestors` list). Each entry is a bare https origin, with no path, query or wildcard (`http://localhost:<port>` only outside production); an invalid entry refuses to boot. A robot is embeddable only after its owner turns on `embed_public` with the form on its panel (`POST /robots/:id/embed`); the flag is web-only and never appears in `/api/v1`. Migration `0005_embed_public.sql` adds the column, off for every robot.
Set `BOT_EMBED_ORIGINS` (comma or space separated; default `https://openvibe.live,https://www.openvibe.live`) to the pages that may frame a robot's read-only panel (the CSP `frame-ancestors` list). Each entry is a bare https origin, with no path, query or wildcard (`http://localhost:<port>` only outside production); an invalid entry refuses to boot. A robot is embeddable only after its owner turns on `embed_public` with the form on its panel (`POST /robots/:id/embed`); the flag is web-only and never appears in `/api/v1`. Migration `0005_embed_public.sql` adds the column, off for every robot.
+The embeddable panel is `GET /panel/:id/embed` (framed only by `BOT_EMBED_ORIGINS`; no cookie needed to watch) and its read-only socket is `wss://openvibe.bot/watch` (docs/protocol.md §2.1); nginx must upgrade `/watch` like `/control`. Set `BOT_WATCH_MAX_PER_IP` (default 20) and `BOT_WATCH_MAX_PER_ROBOT` (default 500) to cap open `/watch` sockets per client address and watchers per robot; over a cap the socket closes 4003.
+
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_OPENRE_TOKEN` (a Network service token holding `openre.stream.read`, `openre.stream.write` and `openre.key.rotate`, or `openre.stream.*` with `openre.key.rotate`; `BOT_OPENRE_TIMEOUT_MS`, default 8000, bounds each call). 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 either variable unset, 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_OPENRE_TOKEN` (a Network service token holding `openre.stream.read`, `openre.stream.write` and `openre.key.rotate`, or `openre.stream.*` with `openre.key.rotate`; `BOT_OPENRE_TIMEOUT_MS`, default 8000, bounds each call). 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 either variable unset, devices still pair but get no publish key and the answer says `"video": "not_configured"`; Bot mints no key of its own.
@@ -39,7 +39,8 @@
@@ -39,7 +39,8 @@
"signed-in pages: /robots (list and add), /pair/:id (the code and the one-paste installer command) and /panel/:id, rendered from the robot's profile and driven over /control; a sim.rover robot is driven by an in-process simulated device",
"signed-in pages: /robots (list and add), /pair/:id (the code and the one-paste installer command) and /panel/:id, rendered from the robot's profile and driven over /control; a sim.rover robot is driven by an in-process simulated device",
"panel input: a touch joystick, the keyboard and a gamepad (Gamepad API), each held control re-sent while held and zeroed at once on release; a latency meter (round-trip median and telemetry age); one camera tile per camera the profile lists (a placeholder until OpenRe can play a WHIP stream back); a live online indicator on /pair/:id read from /control",
"panel input: a touch joystick, the keyboard and a gamepad (Gamepad API), each held control re-sent while held and zeroed at once on release; a latency meter (round-trip median and telemetry age); one camera tile per camera the profile lists (a placeholder until OpenRe can play a WHIP stream back); a live online indicator on /pair/:id read from /control",
"platform.job@1 jobs dispatched to a paired Node over /device (platform.job-frame@1: resent unacked on reconnect, only to an advertised runtime class) and metered per second into run_usage_outbox for billing.usage.record",
"platform.job@1 jobs dispatched to a paired Node over /device (platform.job-frame@1: resent unacked on reconnect, only to an advertised runtime class) and metered per second into run_usage_outbox for billing.usage.record",
- "embed opt-in: robots.embed_public (migration 0005), set only by the owner with POST /robots/:id/embed, web-only (not in /api/v1); BOT_EMBED_ORIGINS is the validated frame-ancestors allow-list for the embeddable panel (default OpenVibe.Live), the embed view itself follows in a later step"
+ "embed opt-in: robots.embed_public (migration 0005), set only by the owner with POST /robots/:id/embed, web-only (not in /api/v1); BOT_EMBED_ORIGINS is the validated frame-ancestors allow-list for the embeddable panel (default OpenVibe.Live)",
+ "embeddable panel: GET /panel/:id/embed framed only by BOT_EMBED_ORIGINS (no cookie, never a sign-in redirect; a member keeps their role, anyone else watches an embed_public robot) and the anonymous read-only /watch socket carrying the public state (online, e-stop, latency, battery, the profile's readouts), capped per address and per robot"
],
],
"updated": "2026-10-04"
"updated": "2026-10-04"
}
}
@@ -3,8 +3,8 @@
@@ -3,8 +3,8 @@
##
##
## Deployed to /etc/nginx/sites-available/openvibe.bot.conf — keep them in sync and reload nginx
## Deployed to /etc/nginx/sites-available/openvibe.bot.conf — keep them in sync and reload nginx
## after changing it. The Node app (port 4630, systemd unit openvibe-bot) serves the REST API and
## after changing it. The Node app (port 4630, systemd unit openvibe-bot) serves the REST API and
-## terminates the device (`/device`) and operator (`/control`) WebSockets; nginx terminates TLS,
-## redirects www → apex, upgrades the two WebSocket routes, rate-limits sign-in and the API, and
+## terminates the device (`/device`), operator (`/control`) and watch (`/watch`) WebSockets; nginx terminates TLS,
+## redirects www → apex, upgrades the three WebSocket routes, rate-limits sign-in and the API, and
## serves the existing OpenVibe.Sites public page and shared assets.
## serves the existing OpenVibe.Sites public page and shared assets.
##
##
## No proxy_cache: the app decides cacheability per response (no-store on the API), and device and
## No proxy_cache: the app decides cacheability per response (no-store on the API), and device and
@@ -105,6 +105,20 @@ server {
@@ -105,6 +105,20 @@ server {
proxy_send_timeout 3600s;
proxy_send_timeout 3600s;
}
}
+ # ── Watch WebSocket: anonymous and read-only, for the embeddable panel (plan T15 R9) ──
+ # No credential; the app caps sockets per address (BOT_WATCH_MAX_PER_IP) and per robot, and the
+ # upgrade itself is rate-limited here like any anonymous request.
+ location = /watch {
+ limit_req zone=ovbot_api burst=20 nodelay;
+ limit_req_status 429;
+ proxy_pass http://127.0.0.1:4630;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection $connection_upgrade;
+ proxy_buffering off;
+ proxy_read_timeout 3600s;
+ proxy_send_timeout 3600s;
+ }
+
# ── Network SSO (sign-in, callback, sign-out) ───────────
# ── Network SSO (sign-in, callback, sign-out) ───────────
location /auth/ {
location /auth/ {
limit_req zone=ovbot_auth burst=10 nodelay;
limit_req zone=ovbot_auth burst=10 nodelay;
@@ -1,11 +1,13 @@
@@ -1,11 +1,13 @@
# OpenVibe.Bot — the device and control protocol
# OpenVibe.Bot — the device and control protocol
Everything here is exactly what `server/realtime.js` implements today; the device agent job builds
Everything here is exactly what `server/realtime.js` implements today; the device agent job builds
-against this document. Two WebSockets share one hub:
+against this document. Three WebSockets share one hub:
- `wss://openvibe.bot/device` — one **outbound** connection per device (ADR-043 decision 4). It works
- `wss://openvibe.bot/device` — one **outbound** connection per device (ADR-043 decision 4). It works
behind any home router and on an ESP32; video goes separately over WHIP to OpenRe.
behind any home router and on an ESP32; video goes separately over WHIP to OpenRe.
- `wss://openvibe.bot/control` — operators (a signed-in person, or a service acting for one).
- `wss://openvibe.bot/control` — operators (a signed-in person, or a service acting for one).
+- `wss://openvibe.bot/watch` — anyone, read-only: the public state of a robot whose owner allows embedding
+ (§2.1, the embeddable panel).
Every frame is JSON with three envelope fields on **every** message:
Every frame is JSON with three envelope fields on **every** message:
@@ -365,6 +367,43 @@ Refusal codes (the gate, in order): `bot.robot_not_found`, `bot.unknown_command`
@@ -365,6 +367,43 @@ Refusal codes (the gate, in order): `bot.robot_not_found`, `bot.unknown_command`
`halt` is never refused for the e-stop, the allowlist, a cooldown or the turn budget; it still needs a
`halt` is never refused for the e-stop, the allowlist, a cooldown or the turn budget; it still needs a
role that may drive (a queue robot's turn holder, not someone waiting) and an online device.
role that may drive (a queue robot's turn holder, not someone waiting) and an online device.
+### 2.1 `/watch` and the embeddable panel (plan T15 R9)
+
+`GET /panel/:id/embed` is the panel for a frame on another site. It never redirects to sign in and sets no
+cookie. A signed-in visitor (the `ov_token` cookie) who is a member gets their role exactly as `/panel/:id`
+decides (a stranger on a `queue` robot: `queue`) and drives over `/control`; anyone else gets the role
+`watcher` when the owner turned on `embed_public` (`POST /robots/:id/embed`), with every control disabled
+and a "Sign in to control" link that opens `/panel/:id` in a new tab; otherwise `403` with only a link out.
+An unknown robot is `404`. The e-stop state is always shown; the E-stop button only to an owner or operator.
+Its response carries `Content-Security-Policy: default-src 'self'; frame-ancestors 'self' <BOT_EMBED_ORIGINS>;
+object-src 'none'; base-uri 'self'` and `Cache-Control: no-store`; every other page keeps
+`frame-ancestors 'self'`.
+
+The watcher's panel joins on `wss://openvibe.bot/watch`: no credential, never a cookie or token.
+
+| client → server | fields | answer |
+|---|---|---|
+| `join` | `robot_id` | `joined { role: "watcher", profile, allowed_commands: [], state }` for an `embed_public` robot; otherwise (or an unknown robot) `error` `bot.not_an_operator` |
+| `leave` | — | unsubscribe |
+| anything else (`command`, `estop`, `estop_clear`, …) | — | `error` `bot.read_only`; never reaches a device or the audit |
+
+A joined watcher then gets `robot_state { state }` on every change, as a `/control` subscriber does, but always
+the **public state**: `{ robot_id, online, estop{latched}, latency_ms, battery, telemetry }`, where
+`telemetry` is `null` or `{ sensors: { … } }` holding only the keys the profile's `telemetry` widgets read
+(`sensor.ultrasonic` → `ultrasonic`). Never the queue, a subject, who latched the e-stop, the device's ids,
+its `status` or the rest of its telemetry.
+
+Frames over 4 KiB close the socket (1009). Over `BOT_WATCH_MAX_PER_IP` open sockets from one client address
+(default 20) or `BOT_WATCH_MAX_PER_ROBOT` watchers on one robot (default 500) the socket closes **4003**; the
+owner turning `embed_public` off closes the robot's watchers with 4003 too.
+
+```json
+{ "v": 1, "seq": 1, "ts": 1738065600001, "type": "joined", "role": "watcher", "profile": { "id": "adeept.adr036" },
+ "allowed_commands": [],
+ "state": { "robot_id": "rob_01J8Z4M2Q0R7T9YV3K6N8P1W2X", "online": true, "estop": { "latched": false },
+ "latency_ms": 63, "battery": 0.72, "telemetry": { "sensors": { "ultrasonic": 41.5 } } } }
+```
+
---
---
## 3. REST (the same gate, HTTP)
## 3. REST (the same gate, HTTP)
@@ -18,13 +18,16 @@
@@ -18,13 +18,16 @@
* 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
*
*
+ * 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.
+ *
* Also on the pairing page: [data-copy] copies the installer command, and [data-pair-robot] joins the robot on
* Also on the pairing page: [data-copy] copies the installer command, and [data-pair-robot] joins the robot on
* /control (the owner's own, read-only use of it) to flip "Waiting for the device." when the device connects.
* /control (the owner's own, read-only use of it) to flip "Waiting for the device." when the device connects.
*/
*/
(() => {
(() => {
const $ = (sel, root = document) => root.querySelector(sel);
const $ = (sel, root = document) => root.querySelector(sel);
const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
- const wsUrl = () => `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/control`;
+ const wsUrl = (path = '/control') => `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}${path}`;
for (const b of $$('[data-copy]')) {
for (const b of $$('[data-copy]')) {
b.addEventListener('click', () => {
b.addEventListener('click', () => {
@@ -69,6 +72,7 @@
@@ -69,6 +72,7 @@
const main = document.getElementById('panel');
const main = document.getElementById('panel');
if (!main) return;
if (!main) return;
const ROBOT_ID = main.dataset.robotId;
const ROBOT_ID = main.dataset.robotId;
+ const WATCHER = main.dataset.role === 'watcher';
const HOLD_MS = Number(main.dataset.holdMs) || 150;
const HOLD_MS = Number(main.dataset.holdMs) || 150;
const MIN_GAP_MS = 40; // a moving stick sends at most this often between the hold re-sends
const MIN_GAP_MS = 40; // a moving stick sends at most this often between the hold re-sends
const STICK_DEADZONE = 0.12; // touch joystick, fraction of its radius
const STICK_DEADZONE = 0.12; // touch joystick, fraction of its radius
@@ -92,6 +96,7 @@
@@ -92,6 +96,7 @@
}
}
function raw(frame) {
function raw(frame) {
if (!ws || ws.readyState !== WebSocket.OPEN) return false;
if (!ws || ws.readyState !== WebSocket.OPEN) return false;
+ if (WATCHER && frame.type !== 'join') return false; // /watch is read-only
ws.send(JSON.stringify({ v: 1, ...frame }));
ws.send(JSON.stringify({ v: 1, ...frame }));
return true;
return true;
}
}
@@ -491,7 +496,7 @@
@@ -491,7 +496,7 @@
}
}
function connect() {
function connect() {
- ws = new WebSocket(wsUrl());
+ ws = new WebSocket(wsUrl(WATCHER ? '/watch' : '/control'));
ws.onopen = () => raw({ type: 'join', robot_id: ROBOT_ID });
ws.onopen = () => raw({ type: 'join', robot_id: ROBOT_ID });
ws.onmessage = (e) => { let m; try { m = JSON.parse(e.data); } catch { return; } onFrame(m); };
ws.onmessage = (e) => { let m; try { m = JSON.parse(e.data); } catch { return; } onFrame(m); };
ws.onclose = (e) => {
ws.onclose = (e) => {
@@ -184,6 +184,11 @@ function loadConfig(env = process.env) {
@@ -184,6 +184,11 @@ function loadConfig(env = process.env) {
// chooses the target.
// chooses the target.
// Embeddable panel (plan T15 R9): the origins whose pages may frame a robot's read-only panel.
// Embeddable panel (plan T15 R9): the origins whose pages may frame a robot's read-only panel.
embed: { origins: embedOrigins },
embed: { origins: embedOrigins },
+ // The anonymous read-only /watch socket: open sockets per client address and watchers per robot; over → close 4003.
+ watch: {
+ maxPerIp: Math.max(1, int(env.BOT_WATCH_MAX_PER_IP, 20)),
+ maxPerRobot: Math.max(1, int(env.BOT_WATCH_MAX_PER_ROBOT, 500)),
+ },
installer: {
installer: {
scriptUrl: trim(env.BOT_INSTALLER_URL || 'https://openvibe.bot/install'),
scriptUrl: trim(env.BOT_INSTALLER_URL || 'https://openvibe.bot/install'),
sourceUrl: installerSource,
sourceUrl: installerSource,
@@ -13,6 +13,10 @@
@@ -13,6 +13,10 @@
* wss://…/control a signed-in person (Network session cookie or Bearer) or a service token holding
* wss://…/control a signed-in person (Network session cookie or Bearer) or a service token holding
* `bot.robot.control` acting for `X-OV-Subject`. Joins a robot, sends commands; the
* `bot.robot.control` acting for `X-OV-Subject`. Joins a robot, sends commands; the
* gate lives in the domain and every decision is audited.
* gate lives in the domain and every decision is audited.
+ * wss://…/watch anyone, no credential (plan T15 R9): joins a robot whose owner turned on embed_public and
+ * receives its public state only (robotState → publicState). Read-only: any frame but
+ * join/leave answers bot.read_only and never reaches a device or the audit. Capped per client
+ * address and per robot (config.watch); over a cap the socket closes 4003.
*
*
* Every frame carries v, seq, ts. A command's `id` is an idempotency key: a repeated id answers with the
* Every frame carries v, seq, ts. A command's `id` is an idempotency key: a repeated id answers with the
* first result instead of reaching the device again.
* first result instead of reaching the device again.
@@ -29,6 +33,7 @@ const MAX_BACKLOG = 64; // frames a device may send before its authentication
@@ -29,6 +33,7 @@ const MAX_BACKLOG = 64; // frames a device may send before its authentication
const ACCESS_COOKIE = 'ov_token';
const ACCESS_COOKIE = 'ov_token';
const DEVICE_KINDS = new Set(['onboard', 'bridge', 'server']);
const DEVICE_KINDS = new Set(['onboard', 'bridge', 'server']);
const MAX_AGENT_VERSION = 40;
const MAX_AGENT_VERSION = 40;
+const WATCH_MAX_PAYLOAD = 4 * 1024; // a watcher only ever sends join/leave
function parseCookies(header) {
function parseCookies(header) {
const out = {};
const out = {};
@@ -53,6 +58,9 @@ function createRealtime({ config, keys, userAuth, usage = null, log = console, n
@@ -53,6 +58,9 @@ function createRealtime({ config, keys, userAuth, usage = null, log = console, n
const deviceWss = new WebSocketServer({ noServer: true, maxPayload: 64 * 1024, perMessageDeflate: false });
const deviceWss = new WebSocketServer({ noServer: true, maxPayload: 64 * 1024, perMessageDeflate: false });
const controlWss = new WebSocketServer({ noServer: true, maxPayload: 64 * 1024, perMessageDeflate: false });
const controlWss = new WebSocketServer({ noServer: true, maxPayload: 64 * 1024, perMessageDeflate: false });
+ const watchWss = new WebSocketServer({ noServer: true, maxPayload: WATCH_MAX_PAYLOAD, perMessageDeflate: false });
+ const watchConns = new Set(); // every /watch socket, joined or not
+ const watchersByAddr = new Map(); // client address → open /watch sockets
const offlineAfterMs = config.device.heartbeatMs * config.device.offlineMisses + config.device.offlineGraceMs;
const offlineAfterMs = config.device.heartbeatMs * config.device.offlineMisses + config.device.offlineGraceMs;
const jobs = createJobFrames({ db: () => domain.db, usage, log, now });
const jobs = createJobFrames({ db: () => domain.db, usage, log, now });
@@ -77,6 +85,7 @@ function createRealtime({ config, keys, userAuth, usage = null, log = console, n
@@ -77,6 +85,7 @@ function createRealtime({ config, keys, userAuth, usage = null, log = console, n
try { path = new URL(req.url, 'http://localhost').pathname; } catch { return socket.destroy(); }
try { path = new URL(req.url, 'http://localhost').pathname; } catch { return socket.destroy(); }
if (path === '/device') return deviceWss.handleUpgrade(req, socket, head, (ws) => onDeviceSocket(ws, req));
if (path === '/device') return deviceWss.handleUpgrade(req, socket, head, (ws) => onDeviceSocket(ws, req));
if (path === '/control') return controlWss.handleUpgrade(req, socket, head, (ws) => onControlSocket(ws, req));
if (path === '/control') return controlWss.handleUpgrade(req, socket, head, (ws) => onControlSocket(ws, req));
+ if (path === '/watch') return watchWss.handleUpgrade(req, socket, head, (ws) => onWatchSocket(ws, req));
socket.destroy();
socket.destroy();
}
}
@@ -487,6 +496,68 @@ function createRealtime({ config, keys, userAuth, usage = null, log = console, n
@@ -487,6 +496,68 @@ function createRealtime({ config, keys, userAuth, usage = null, log = console, n
broadcast(robotId);
broadcast(robotId);
}
}
+ // ── Watcher side (read-only, anonymous) ───────────────────────────────────────────────────────────
+ /** The caller's address as Express decides it with the same trust proxy hop count (config.trustProxy). */
+ function clientAddress(req) {
+ const forwarded = String(req.headers['x-forwarded-for'] || '').split(',').map((s) => s.trim()).filter(Boolean).reverse();
+ const chain = [req.socket.remoteAddress || '', ...forwarded];
+ const hops = Number.isInteger(config.trustProxy) && config.trustProxy > 0 ? config.trustProxy : 0;
+ return chain[Math.min(hops, chain.length - 1)];
+ }
+
+ function onWatchSocket(ws, req) {
+ const addr = clientAddress(req);
+ const conn = { ws, seq: 0, watcher: true, subject: null, robotId: null, role: 'watcher', readouts: [], addr };
+ ws.conn = conn;
+ watchConns.add(conn);
+ watchersByAddr.set(addr, (watchersByAddr.get(addr) || 0) + 1);
+ ws.on('close', () => {
+ unsubscribe(conn);
+ watchConns.delete(conn);
+ const n = (watchersByAddr.get(addr) || 1) - 1;
+ if (n > 0) watchersByAddr.set(addr, n); else watchersByAddr.delete(addr);
+ });
+ ws.on('error', () => { /* the close event does the work */ });
+ if (watchersByAddr.get(addr) > config.watch.maxPerIp) return ws.close(4003, 'too many watchers from this address');
+ ws.on('message', (raw) => handleWatchMessage(conn, raw).catch((e) => { log.warn(`[Bot] watch: ${e.message}`); sendError(conn, 'bot.internal', null); }));
+ return undefined;
+ }
+
+ async function handleWatchMessage(conn, raw) {
+ let msg; try { msg = JSON.parse(String(raw)); } catch { return sendError(conn, 'bot.bad_json', 'frames must be JSON'); }
+ if (!msg || typeof msg !== 'object') return sendError(conn, 'bot.bad_frame', 'a frame must be an object');
+ if (msg.type === 'join') return onWatchJoin(conn, msg);
+ if (msg.type === 'leave') { unsubscribe(conn); conn.robotId = null; return undefined; }
+ return sendError(conn, 'bot.read_only', 'this socket only watches; sign in on openvibe.bot to control');
+ }
+
+ async function onWatchJoin(conn, msg) {
+ const robotId = String(msg.robot_id || '').slice(0, 64);
+ const robot = robotId ? await domain.robots.get(robotId) : null;
+ // An unknown robot and a robot that is not public answer alike: an anonymous caller learns nothing.
+ if (!robot || !robot.embed_public) return sendError(conn, 'bot.not_an_operator', 'this robot is not public');
+ if (conn.robotId !== robotId) {
+ const set = subsByRobot.get(robotId);
+ let watching = 0;
+ if (set) for (const c of set) if (c.watcher) watching++;
+ if (watching >= config.watch.maxPerRobot) return conn.ws.close(4003, 'too many watchers on this robot');
+ if (conn.robotId) unsubscribe(conn);
+ }
+ const profileRow = await getProfile(domain.db, robot.profile_id, robot.profile_version);
+ const profile = profileRow ? profileRow.profile : null;
+ conn.robotId = robotId;
+ conn.readouts = readoutKeys(profile);
+ subscribe(conn);
+ return sendFrame(conn, 'joined', { role: 'watcher', profile, allowed_commands: [], state: publicState(await robotState(robotId), conn.readouts) });
+ }
+
+ /** The owner turned embed_public off: every watcher of the robot is dropped at once. */
+ function closeWatchers(robotId, reason = 'this robot is no longer public') {
+ const set = subsByRobot.get(robotId);
+ if (!set) return;
+ for (const c of [...set]) if (c.watcher) { unsubscribe(c); try { c.ws.close(4003, reason); } catch { /* gone */ } }
+ }
+
// ── State and helpers ─────────────────────────────────────────────────────────────────────────
// ── State and helpers ─────────────────────────────────────────────────────────────────────────
function deviceForRobot(robotId) {
function deviceForRobot(robotId) {
let sim = null;
let sim = null;
@@ -538,10 +609,40 @@ function createRealtime({ config, keys, userAuth, usage = null, log = console, n
@@ -538,10 +609,40 @@ function createRealtime({ config, keys, userAuth, usage = null, log = console, n
if (robot && robot.access_policy === 'queue') state.queue = await domain.queue.state(robotId, subjectForQueue);
if (robot && robot.access_policy === 'queue') state.queue = await domain.queue.state(robotId, subjectForQueue);
return state;
return state;
}
}
+ /** The telemetry keys a profile's readout widgets show (`telemetry` widgets, `sensor.<key>` capabilities). */
+ function readoutKeys(profile) {
+ const keys = new Set();
+ for (const w of (profile && profile.widgets) || []) {
+ if (w && w.type === 'telemetry' && typeof w.capability === 'string' && w.capability) keys.add(w.capability.replace(/^sensor\./, ''));
+ }
+ return [...keys];
+ }
+ /**
+ * What a watcher may see: online, the latch, latency, battery and the readouts' sensor values. Never the
+ * queue, a subject, the device's ids, its status or the rest of its telemetry.
+ */
+ function publicState(state, readouts) {
+ const t = state.telemetry;
+ const sensors = t && t.sensors && typeof t.sensors === 'object' ? t.sensors : {};
+ const picked = {};
+ for (const k of readouts) if (Object.prototype.hasOwnProperty.call(sensors, k)) picked[k] = sensors[k];
+ return {
+ robot_id: state.robot_id, online: state.online,
+ estop: { latched: !!(state.estop && state.estop.latched) },
+ latency_ms: state.latency_ms, battery: state.battery,
+ telemetry: t ? { sensors: picked } : null,
+ };
+ }
function broadcast(robotId) {
function broadcast(robotId) {
const subs = subsByRobot.get(robotId);
const subs = subsByRobot.get(robotId);
if (!subs || !subs.size) return;
if (!subs || !subs.size) return;
+ let watched = null; // one state for every watcher (no subject, no queue view)
for (const sub of [...subs]) {
for (const sub of [...subs]) {
+ if (sub.watcher) {
+ watched = watched || robotState(robotId);
+ watched.then((state) => sendFrame(sub, 'robot_state', { state: publicState(state, sub.readouts) })).catch(() => {});
+ continue;
+ }
robotState(robotId, sub.subject).then((state) => sendFrame(sub, 'robot_state', { state })).catch(() => {});
robotState(robotId, sub.subject).then((state) => sendFrame(sub, 'robot_state', { state })).catch(() => {});
}
}
}
}
@@ -568,7 +669,7 @@ function createRealtime({ config, keys, userAuth, usage = null, log = console, n
@@ -568,7 +669,7 @@ function createRealtime({ config, keys, userAuth, usage = null, log = console, n
return {
return {
bindDomain(d) { domain = d; },
bindDomain(d) { domain = d; },
handleUpgrade, attachSim, detachSim,
handleUpgrade, attachSim, detachSim,
- isOnline, deviceState, sendToDevice, sendToRobotDevices, broadcast, closeDevice, robotState, refreshConfig, pushEstop,
+ isOnline, deviceState, sendToDevice, sendToRobotDevices, broadcast, closeDevice, robotState, refreshConfig, pushEstop, closeWatchers,
jobStdout: (jobId) => jobs.stdout(jobId),
jobStdout: (jobId) => jobs.stdout(jobId),
// Real devices only: a simulator is not a machine anyone runs.
// Real devices only: a simulator is not a machine anyone runs.
onlineCount() { let n = 0; for (const c of deviceConns.values()) if (c.online && !c.sim) n++; return n; },
onlineCount() { let n = 0; for (const c of deviceConns.values()) if (c.online && !c.sim) n++; return n; },
@@ -581,9 +682,10 @@ function createRealtime({ config, keys, userAuth, usage = null, log = console, n
@@ -581,9 +682,10 @@ function createRealtime({ config, keys, userAuth, usage = null, log = console, n
inflight.clear();
inflight.clear();
for (const conn of [...deviceConns.values()]) { try { conn.ws.close(1001, 'server closing'); } catch { /* gone */ } }
for (const conn of [...deviceConns.values()]) { try { conn.ws.close(1001, 'server closing'); } catch { /* gone */ } }
for (const set of subsByRobot.values()) for (const conn of set) { try { conn.ws.close(1001, 'server closing'); } catch { /* gone */ } }
for (const set of subsByRobot.values()) for (const conn of set) { try { conn.ws.close(1001, 'server closing'); } catch { /* gone */ } }
- deviceConns.clear(); subsByRobot.clear();
+ for (const conn of watchConns) { try { conn.ws.close(1001, 'server closing'); } catch { /* gone */ } }
+ deviceConns.clear(); subsByRobot.clear(); watchConns.clear(); watchersByAddr.clear();
await Promise.all([
await Promise.all([
- new Promise((r) => deviceWss.close(r)), new Promise((r) => controlWss.close(r)),
+ new Promise((r) => deviceWss.close(r)), new Promise((r) => controlWss.close(r)), new Promise((r) => watchWss.close(r)),
]).catch(() => {});
]).catch(() => {});
},
},
};
};
@@ -7,7 +7,9 @@
@@ -7,7 +7,9 @@
* 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 })
+ * renderPanel({ robot, profile, role, allowed_commands, holdResendMs, mode, signedIn })
+ * mode 'embed': the framed panel (no topbar, no owner form, links open a new tab)
+ * 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 })
* renderPairingPage({ robot, pairing, profile })
* renderPairingPage({ robot, pairing, profile })
@@ -26,7 +28,7 @@ const STICK_X = ['steer', 'x', 'rotation'];
@@ -26,7 +28,7 @@ const STICK_X = ['steer', 'x', 'rotation'];
const STICK_Y = ['throttle', 'y'];
const STICK_Y = ['throttle', 'y'];
const POLICIES = [['private', 'Only me and people I add'], ['invite', 'People I invite'], ['queue', 'Anyone, taking turns']];
const POLICIES = [['private', 'Only me and people I add'], ['invite', 'People I invite'], ['queue', 'Anyone, taking turns']];
const POLICY_LABEL = Object.fromEntries(POLICIES.map(([v]) => [v, { private: 'Private', invite: 'Invite only', queue: 'Open queue' }[v]]));
const POLICY_LABEL = Object.fromEntries(POLICIES.map(([v]) => [v, { private: 'Private', invite: 'Invite only', queue: 'Open queue' }[v]]));
-const ROLE_LABEL = { owner: 'Owner', operator: 'Operator', viewer: 'Viewer', queue: 'In the queue' };
+const ROLE_LABEL = { owner: 'Owner', operator: 'Operator', viewer: 'Viewer', queue: 'In the queue', watcher: 'Watching' };
// ── Icons: 24×24 strokes in currentColor, decorative (the text beside them is the label) ─────────────────────
// ── Icons: 24×24 strokes in currentColor, decorative (the text beside them is the label) ─────────────────────
const PATHS = {
const PATHS = {
@@ -218,7 +220,11 @@ function embedForm(robot) {
@@ -218,7 +220,11 @@ function embedForm(robot) {
</form>`;
</form>`;
}
}
-function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs = 150 }) {
+/** A link out of a frame: always a new tab, never with a handle back to the framing page. */
+const outLink = (href, text, cls = 'button') => `<a class="${cls}" href="${esc(href)}" target="_blank" rel="noopener">${esc(text)}</a>`;
+
+function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs = 150, mode = 'page', signedIn = true }) {
+ 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))
@@ -226,7 +232,7 @@ function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs
@@ -226,7 +232,7 @@ function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs
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}">
-${topbar(`<span class="crumb">${esc(robot.name)}</span>`)}
+${embed ? '' : topbar(`<span class="crumb">${esc(robot.name)}</span>`)}
<div class="estop-bar"><p class="estop-state" role="status" aria-live="assertive"><span class="estop-dot"></span><span data-estop-state>${latched ? 'E-stop latched' : 'E-stop clear'}</span><span class="estop-note">${latched ? ' — the robot stays still until the owner clears it.' : ''}</span></p>${clear}${estop}</div>
<div class="estop-bar"><p class="estop-state" role="status" aria-live="assertive"><span class="estop-dot"></span><span data-estop-state>${latched ? 'E-stop latched' : 'E-stop clear'}</span><span class="estop-note">${latched ? ' — the robot stays still until the owner clears it.' : ''}</span></p>${clear}${estop}</div>
</header>
</header>
<main id="panel" data-robot-id="${esc(robot.id)}" data-role="${esc(role)}" data-hold-ms="${esc(holdResendMs)}" data-allowed="${data(allowed_commands)}">
<main id="panel" data-robot-id="${esc(robot.id)}" data-role="${esc(role)}" data-hold-ms="${esc(holdResendMs)}" data-allowed="${data(allowed_commands)}">
@@ -234,13 +240,23 @@ ${topbar(`<span class="crumb">${esc(robot.name)}</span>`)}
@@ -234,13 +240,23 @@ ${topbar(`<span class="crumb">${esc(robot.name)}</span>`)}
<h1>${esc(robot.name)}</h1>
<h1>${esc(robot.name)}</h1>
<p class="meta"><span class="pill online" data-online-pill data-state="connecting"><span class="dot"></span><span data-online-label>Connecting…</span></span> <span class="pill">${esc(profile.name || robot.profile_id)}</span> <span class="pill role" data-role-label>${esc(ROLE_LABEL[role] || role)}</span></p>
<p class="meta"><span class="pill online" data-online-pill data-state="connecting"><span class="dot"></span><span data-online-label>Connecting…</span></span> <span class="pill">${esc(profile.name || robot.profile_id)}</span> <span class="pill role" data-role-label>${esc(ROLE_LABEL[role] || role)}</span></p>
</div>
</div>
+${embed && role === 'watcher' && !signedIn ? `<p class="embed-sign-in">${outLink(`/panel/${robot.id}`, 'Sign in to control')}</p>` : ''}
<p class="notice" data-notice role="alert" hidden></p>
<p class="notice" data-notice role="alert" hidden></p>
<div class="widgets">
<div class="widgets">
${widgets.join('\n')}
${widgets.join('\n')}
</div>
</div>
-${role === 'owner' ? embedForm(robot) : ''}
+${role === 'owner' && !embed ? embedForm(robot) : ''}
+</main>`;
+ return page(robot.name, body, { scripts: ['/panel/panel.js'], bodyClass: embed ? 'embed-page' : 'panel-page' });
+}
+
+/** The framed 403: no robot data, only a way out to openvibe.bot (where signing in may grant access). */
+function renderEmbedRefused({ robotId }) {
+ const body = `<main class="page embed-refused">
+<div class="card"><p>This robot is not shared for embedding.</p>
+<p>${outLink(`/panel/${robotId}`, 'Open on openvibe.bot', 'button primary')}</p></div>
</main>`;
</main>`;
- return page(robot.name, body, { scripts: ['/panel/panel.js'], bodyClass: 'panel-page' });
+ return page('Not shared', body, { bodyClass: 'embed-page' });
}
}
function renderRobotsPage({ robots = [], profiles = [], error = null, values = {} }) {
function renderRobotsPage({ robots = [], profiles = [], error = null, values = {} }) {
@@ -280,4 +296,4 @@ function renderPairingPage({ robot, pairing, profile = null }) {
@@ -280,4 +296,4 @@ function renderPairingPage({ robot, pairing, profile = null }) {
return page(`Pair ${robot.name}`, body, { scripts: ['/panel/panel.js'] });
return page(`Pair ${robot.name}`, body, { scripts: ['/panel/panel.js'] });
}
}
-module.exports = { renderPanel, renderWidget, renderRobotsPage, renderPairingPage, camerasOf, esc };
+module.exports = { renderPanel, renderEmbedRefused, renderWidget, renderRobotsPage, renderPairingPage, camerasOf, esc };
@@ -10,6 +10,10 @@
@@ -10,6 +10,10 @@
* GET /pair/:id the owner only: a fresh pairing code and the installer command (never minted for a
* GET /pair/:id the owner only: a fresh pairing code and the installer command (never minted for a
* cross-site navigation or a prefetch: those go to /robots)
* cross-site navigation or a prefetch: those go to /robots)
* GET /panel/:id a member (or anyone on a `queue` robot): the panel, rendered from the profile
* GET /panel/:id a member (or anyone on a `queue` robot): the panel, rendered from the profile
+ * GET /panel/:id/embed the panel for a frame (plan T15 R9): a signed-in member's role as /panel/:id decides it,
+ * otherwise `watcher` (video and readouts, no control) when the owner allows embedding,
+ * otherwise 403 with a link out. Never redirects to sign in; framed only by the
+ * BOT_EMBED_ORIGINS allow-list (CSP frame-ancestors), every other page by itself
* POST /robots/:id/embed the owner only: allow (embed_public=on) or stop (off) anonymous read-only embedding → 303 to its panel
* POST /robots/:id/embed the owner only: allow (embed_public=on) or stop (off) anonymous read-only embedding → 303 to its panel
* 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)
*
*
@@ -20,8 +24,9 @@
@@ -20,8 +24,9 @@
const path = require('path');
const path = require('path');
const express = require('express');
const express = require('express');
const { getProfile, listProfiles } = require('../profiles');
const { getProfile, listProfiles } = require('../profiles');
+const { frameAncestors } = require('../config');
const { BotError } = require('../util');
const { BotError } = require('../util');
-const { renderPanel, renderRobotsPage, renderPairingPage } = require('./render');
+const { renderPanel, renderEmbedRefused, renderRobotsPage, renderPairingPage } = require('./render');
const VERSION = require('../../package.json').version;
const VERSION = require('../../package.json').version;
const PUBLIC = path.join(__dirname, '..', '..', 'public');
const PUBLIC = path.join(__dirname, '..', '..', 'public');
@@ -125,27 +130,48 @@ function createWebRoutes(config, { domain = null, sim = null, limits = null, log
@@ -125,27 +130,48 @@ function createWebRoutes(config, { domain = null, sim = null, limits = null, log
const value = Array.isArray(sent) ? sent[sent.length - 1] : sent;
const value = Array.isArray(sent) ? sent[sent.length - 1] : sent;
if (value !== 'on' && value !== 'off') throw new BotError(422, 'bot.invalid_input', 'embed_public must be on or off');
if (value !== 'on' && value !== 'off') throw new BotError(422, 'bot.invalid_input', 'embed_public must be on or off');
await domain.robots.setEmbedPublic(robot.id, value === 'on');
await domain.robots.setEmbedPublic(robot.id, value === 'on');
+ if (value === 'off' && domain.link && domain.link.closeWatchers) domain.link.closeWatchers(robot.id);
res.redirect(303, `/panel/${robot.id}`);
res.redirect(303, `/panel/${robot.id}`);
}));
}));
+ /** As the /control join decides: a member's role, or a place in the queue on a `queue` robot; else null. */
+ async function memberRole(robot, me) {
+ const role = await domain.members.roleOf(robot.id, me.subject);
+ return role || (robot.access_policy === 'queue' ? 'queue' : null);
+ }
+ async function sendPanel(res, robot, profile, role, mode = 'page', signedIn = true) {
+ await startSim(robot, profile);
+ const { maxCommandMs } = domain.control.effectiveLimits(robot, profile);
+ res.type('html').send(renderPanel({
+ robot: presentForPage(robot), profile, role, mode, signedIn,
+ 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))),
+ }));
+ }
+
r.get('/panel/:id', page(async (req, res) => {
r.get('/panel/:id', page(async (req, res) => {
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');
- // As the /control join decides: a member's role, or a place in the queue on a `queue` robot.
- let role = await domain.members.roleOf(robot.id, me.subject);
- if (!role && robot.access_policy === 'queue') role = 'queue';
+ const role = await memberRole(robot, me);
if (!role) throw new BotError(403, 'bot.not_an_operator', 'you have no access to this robot');
if (!role) throw new BotError(403, 'bot.not_an_operator', 'you have no access to this robot');
const profile = await profileOf(robot);
const profile = await profileOf(robot);
if (!profile) throw new BotError(404, 'bot.profile_not_found', 'this robot has no profile');
if (!profile) throw new BotError(404, 'bot.profile_not_found', 'this robot has no profile');
- await startSim(robot, profile);
- const { maxCommandMs } = domain.control.effectiveLimits(robot, profile);
- res.type('html').send(renderPanel({
- robot: presentForPage(robot), profile, role,
- allowed_commands: 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))),
- }));
+ await sendPanel(res, robot, profile, role);
+ }));
+
+ // 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('; '));
+ 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;
+ if (!role && robot.embed_public) role = 'watcher';
+ if (!role) return res.status(403).type('html').send(renderEmbedRefused({ robotId: robot.id }));
+ const profile = await profileOf(robot);
+ if (!profile) throw new BotError(404, 'bot.profile_not_found', 'this robot has no profile');
+ return sendPanel(res, robot, profile, role, 'embed', !!req.viewer);
}));
}));
return r;
return r;
new file mode 100644
new file mode 100644
@@ -0,0 +1,202 @@
@@ -0,0 +1,202 @@
+'use strict';
+// The embeddable panel (plan T15 step R9, s2): GET /panel/:id/embed framed only by BOT_EMBED_ORIGINS, and the
+// anonymous read-only /watch socket that carries an embed_public robot's public state and nothing else.
+const fs = require('fs');
+const path = require('path');
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/app');
+const { loadConfig } = require('../server/config');
+const { renderPanel } = require('../server/web/render');
+
+(async () => {
+ const SIM = { id: 'sim.rover', name: 'Simulated rover', commands: { drive: { axes: { throttle: [-1, 1], steer: [-1, 1] } } }, widgets: [{ type: 'drive', label: 'Drive', command: { kind: 'drive' } }, { type: 'latency', label: 'Latency' }] };
+ const robotRow = { id: 'rob_e1', name: 'Rover', profile_id: 'sim.rover', estop: { latched: false } };
+
+ await check('BOT_WATCH_MAX_PER_IP and BOT_WATCH_MAX_PER_ROBOT default to 20 and 500', () => {
+ assert.deepStrictEqual(loadConfig({}).watch, { maxPerIp: 20, maxPerRobot: 500 });
+ assert.deepStrictEqual(loadConfig({ BOT_WATCH_MAX_PER_IP: '3', BOT_WATCH_MAX_PER_ROBOT: '0' }).watch, { maxPerIp: 3, maxPerRobot: 1 });
+ });
+
+ await check('embed mode: no topbar, the e-stop state always, controls only for the role, links open a new tab', () => {
+ const watcher = renderPanel({ robot: robotRow, profile: SIM, role: 'watcher', allowed_commands: [], mode: 'embed', signedIn: false });
+ assert.ok(watcher.includes('class="embed-page"') && !watcher.includes('class="topbar"'));
+ assert.ok(watcher.includes('data-estop-banner') && watcher.includes('data-estop-state'));
+ assert.ok(!watcher.includes('data-estop>') && !watcher.includes('data-estop-clear') && !watcher.includes('data-embed-form'));
+ assert.ok(watcher.includes('data-role="watcher"') && watcher.includes('data-allowed="[]"'));
+ assert.ok(!/<button(?![^>]*disabled)[^>]*data-(hold|stop)/.test(watcher), 'a control is enabled for a watcher');
+ assert.match(watcher, /<a class="button" href="\/panel\/rob_e1" target="_blank" rel="noopener">Sign in to control<\/a>/);
+ for (const a of watcher.match(/<a [^>]*>/g)) assert.ok(a.includes('target="_blank"') && a.includes('rel="noopener"'), a);
+ const signedIn = renderPanel({ robot: robotRow, profile: SIM, role: 'watcher', allowed_commands: [], mode: 'embed', signedIn: true });
+ assert.ok(!signedIn.includes('Sign in to control'));
+ const owner = renderPanel({ robot: { ...robotRow, embed_public: true }, profile: SIM, role: 'owner', allowed_commands: ['drive', 'halt'], mode: 'embed' });
+ assert.ok(owner.includes('data-estop>') && owner.includes('data-estop-clear') && !owner.includes('data-embed-form') && !owner.includes('<a '));
+ const page = renderPanel({ robot: robotRow, profile: SIM, role: 'owner', allowed_commands: ['drive'] });
+ 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', () => {
+ 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;/);
+ });
+
+ const t = await boot({ env: { BOT_WATCH_MAX_PER_IP: '2', BOT_WATCH_MAX_PER_ROBOT: '3' } });
+ const alex = t.network.newUser('alex');
+ const bob = t.network.newUser('bob');
+ const cookie = (user) => ({ Cookie: `ov_token=${t.network.signUser(user)}` });
+ const get = (p, user) => fetch(t.base + p, { redirect: 'manual', headers: user ? cookie(user) : {} });
+ 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 auditCount = async (id) => (await t.domain.audit.list(id, { limit: 100 })).length;
+
+ const { robot: open } = await t.robot(alex, { name: 'Public rover' });
+ await t.domain.robots.setEmbedPublic(open.id, true);
+ const { robot: closed } = await t.robot(alex, { name: 'Private rover' });
+
+ await check('GET /panel/:id/embed: a public robot to anyone, with the embed CSP, no cookie and never a redirect', async () => {
+ const r = await get(`/panel/${open.id}/embed`);
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.headers.get('content-security-policy'), EMBED_CSP);
+ assert.strictEqual(r.headers.get('cache-control'), 'no-store');
+ assert.strictEqual(r.headers.get('set-cookie'), null);
+ const html = await r.text();
+ assert.ok(html.includes('data-role="watcher"') && html.includes('data-allowed="[]"') && html.includes('class="embed-page"'));
+ assert.ok(html.includes('Sign in to control') && !html.includes('data-estop>'));
+ assert.ok(t.hub.isOnline(`dev_sim_${open.id}`), 'the embed starts the simulator as the panel does');
+ // The rest of the site still refuses every frame.
+ assert.match((await get(`/panel/${open.id}`, alex)).headers.get('content-security-policy'), /frame-ancestors 'self';/);
+ assert.match((await get('/robots', alex)).headers.get('content-security-policy'), /frame-ancestors 'self';/);
+ });
+
+ await check('a robot that is not public: 403 with a link out and no robot data; unknown: 404; members keep their role', async () => {
+ const r = await get(`/panel/${closed.id}/embed`);
+ assert.strictEqual(r.status, 403);
+ assert.strictEqual(r.headers.get('content-security-policy'), EMBED_CSP);
+ assert.strictEqual(r.headers.get('set-cookie'), null);
+ const html = await r.text();
+ assert.ok(!html.includes('Private rover') && !html.includes('data-robot-id'));
+ assert.match(html, /href="\/panel\/rob_[0-9A-Za-z]+" target="_blank" rel="noopener">Open on openvibe\.bot</);
+ assert.strictEqual((await get(`/panel/${closed.id}/embed`, bob)).status, 403);
+ assert.strictEqual((await get('/panel/rob_nothing/embed')).status, 404);
+ const owner = await get(`/panel/${closed.id}/embed`, alex);
+ assert.strictEqual(owner.status, 200);
+ const oh = await owner.text();
+ assert.ok(oh.includes('data-role="owner"') && oh.includes('data-estop>') && !oh.includes('data-embed-form') && !oh.includes('Sign in to control'));
+ const stranger = await (await get(`/panel/${open.id}/embed`, bob)).text();
+ assert.ok(stranger.includes('data-role="watcher"') && !stranger.includes('Sign in to control'));
+ });
+
+ 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 });
+ assert.strictEqual((await w.waitFor((m) => m.type === 'error')).code, 'bot.not_an_operator');
+ w.send({ type: 'join', robot_id: 'rob_nothing' });
+ await w.waitFor((m) => m.type === 'error' && w.messages.filter((x) => x.type === 'error').length === 2);
+ assert.strictEqual(w.messages[1].code, 'bot.not_an_operator');
+ w.send({ type: 'join', robot_id: open.id });
+ const j = await w.waitFor((m) => m.type === 'joined');
+ assert.strictEqual(j.role, 'watcher');
+ assert.deepStrictEqual(j.allowed_commands, []);
+ assert.strictEqual(j.profile.id, 'sim.rover');
+ assert.strictEqual(j.robot, undefined);
+ assert.deepStrictEqual(Object.keys(j.state).sort(), ['battery', 'estop', 'latency_ms', 'online', 'robot_id', 'telemetry']);
+ assert.deepStrictEqual(Object.keys(j.state.estop), ['latched']);
+ const s = await w.waitFor((m) => m.type === 'robot_state' && m.state.telemetry);
+ assert.deepStrictEqual(s.state.telemetry, { sensors: {} }, 'the drive and pose telemetry reached a watcher');
+ assert.ok(s.state.online && s.state.battery > 0);
+ w.close();
+ });
+
+ await check('every frame but join/leave answers bot.read_only and reaches neither the device nor the audit', async () => {
+ const w = await watch();
+ w.send({ type: 'join', robot_id: open.id });
+ await w.waitFor((m) => m.type === 'joined');
+ const before = await auditCount(open.id);
+ for (const f of [{ type: 'command', id: 'x1', kind: 'drive', value: { throttle: 1 } }, { type: 'command', kind: 'halt' }, { type: 'estop' }, { type: 'estop_clear' }, { type: 'pair' }]) w.send(f);
+ await w.waitFor(() => w.messages.filter((m) => m.type === 'error').length >= 5);
+ assert.deepStrictEqual(w.messages.filter((m) => m.type === 'error').map((m) => m.code), Array(5).fill('bot.read_only'));
+ assert.ok(!w.messages.some((m) => m.type === 'command_result'));
+ await t.wait(100);
+ assert.strictEqual(await auditCount(open.id), before);
+ assert.strictEqual((await t.domain.robots.get(open.id)).estop_latched, false);
+ const tele = (m) => m.type === 'robot_state' && m.state.telemetry;
+ assert.ok(await w.waitFor(tele));
+ assert.strictEqual(t.hub.deviceState(`dev_sim_${open.id}`).telemetry.drive.throttle || 0, 0, 'the robot moved');
+ w.close();
+ });
+
+ await check('the owner\'s e-stop reaches a watcher; readouts carry only the profile\'s sensor keys', async () => {
+ const w = await watch();
+ w.send({ type: 'join', robot_id: open.id });
+ await w.waitFor((m) => m.type === 'joined');
+ const c = await t.ws('/control', { headers: cookie(alex) });
+ c.send({ type: 'join', robot_id: open.id });
+ await c.waitFor((m) => m.type === 'joined');
+ c.send({ type: 'estop' });
+ assert.ok(await w.waitFor((m) => m.type === 'robot_state' && m.state.estop.latched === true));
+ c.send({ type: 'estop_clear' });
+ assert.ok(await w.waitFor((m) => m.type === 'robot_state' && m.state.estop.latched === false && w.messages.some((x) => x.state && x.state.estop.latched)));
+ c.close(); w.close();
+
+ const { robot: arm } = await t.robot(alex, { name: 'Arm', profile_id: 'adeept.adr036' });
+ await t.domain.robots.setEmbedPublic(arm.id, true);
+ const dev = t.hub.attachSim({ id: 'dev_fake_arm', robot_ids: JSON.stringify([arm.id]), kind: 'onboard' }, { onFrame() {} });
+ await dev.ready;
+ const a = await watch();
+ a.send({ type: 'join', robot_id: arm.id });
+ await a.waitFor((m) => m.type === 'joined');
+ dev.deliver({ type: 'telemetry', events: ['bump'], battery: 0.5, sensors: { ultrasonic: 42, secret: 7 }, pose: { x: 1 } });
+ const s = await a.waitFor((m) => m.type === 'robot_state' && m.state.telemetry);
+ assert.deepStrictEqual(s.state.telemetry, { sensors: { ultrasonic: 42 } });
+ assert.strictEqual(s.state.battery, 0.5);
+ a.close();
+ t.hub.detachSim('dev_fake_arm');
+ });
+
+ await check('turning embedding off closes every watcher of the robot with 4003', async () => {
+ const { robot } = await t.robot(alex, { name: 'Briefly public' });
+ await t.domain.robots.setEmbedPublic(robot.id, true);
+ const w = await watch();
+ w.send({ type: 'join', robot_id: robot.id });
+ await w.waitFor((m) => m.type === 'joined');
+ const r = await fetch(`${t.base}/robots/${robot.id}/embed`, { method: 'POST', redirect: 'manual', body: 'embed_public=off', headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...cookie(alex) } });
+ assert.strictEqual(r.status, 303);
+ assert.strictEqual(await w.waitForClose(), 4003);
+ });
+
+ await check('caps: sockets per address and watchers per robot close 4003; an oversized frame closes the socket', async () => {
+ const one = await watch('10.9.9.9');
+ const two = await watch('10.9.9.9');
+ const three = await watch('10.9.9.9');
+ assert.strictEqual(await three.waitForClose(), 4003);
+ one.close(); two.close();
+ await one.waitForClose(); await two.waitForClose();
+ const again = await watch('10.9.9.9');
+ again.send({ type: 'join', robot_id: open.id });
+ assert.ok(await again.waitFor((m) => m.type === 'joined'), 'the address was not released');
+
+ const { robot } = await t.robot(alex, { name: 'Crowded' });
+ await t.domain.robots.setEmbedPublic(robot.id, true);
+ const crowd = [];
+ for (let i = 0; i < 3; i++) {
+ const w = await watch();
+ w.send({ type: 'join', robot_id: robot.id });
+ await w.waitFor((m) => m.type === 'joined');
+ crowd.push(w);
+ }
+ const late = await watch();
+ late.send({ type: 'join', robot_id: robot.id });
+ assert.strictEqual(await late.waitForClose(), 4003);
+ crowd.forEach((w) => w.close());
+
+ again.ws.send('x'.repeat(5000));
+ assert.strictEqual(await again.waitForClose(), 1009);
+ });
+
+ t.app.locals.sim.stopAll();
+ await t.close();
+ done();
+})();