openvibers/openvibe.bot Commits

Bot: drive from the embedded panel with a one-time ticket; a device's local stop is shown as one (#77)

OP OpenVibers committed 11 Oct 2026 · 557db34651 · 12 files, +409 −29

Changes

ddiff --git a/docs/protocol.md b/docs/protocol.md
iindex 64d4a9c..27f4ee6 100644
---- a/docs/protocol.md
++++ b/docs/protocol.md
@@@ -86,7 +86,7 @@ At most 64 frames wait; any more are answered `error` `bot.not_ready`.
| `reauth` | `token` | a Network-paired machine only: a fresh node token for the same principal, before the last one expires (300 s); a refused one is answered `error` `bot.reauth_refused` and moves nothing |
| `ack` | `id` | a command ran (or, keyed by a job id, a job was accepted: §1.2) |
| `nack` | `id, fault_code` | a command (or a job, §1.2) was refused on the device |
--| `estop_state` | `latched, by, at, robot_id?` | the device's own latch, a **report**; `robot_id` omitted = every robot the device serves. See below |
++| `estop_state` | `latched, by, at, robot_id?, local_stop?, reason?` | the device's own latch, a **report**; `robot_id` omitted = every robot the device serves; `local_stop: true` = held on the machine itself. See below |
`kind` is one of `drive`, `actuator`, `ptz`, `say`, `display`, `halt`, and a device only ever gets the
kinds its robot's profile declares (§1.1), with values in the shapes and ranges declared there.
@@@ -172,6 +172,14 @@ only (`robot_state.state.device_estop`); it never clears Bot's latch — clearin
`latched: false`. A `robot_id` the device is not attached to is refused with `error` `bot.forbidden`
and audited (`kind: estop_state`, `result: refused`); nothing changes on that robot.
++**A local stop** (`latched: true, local_stop: true`, with an optional `reason`) is one held on the machine
++itself: `openvibe-node stop`, or a driver's own stop button through it. Only a release on that machine
++(`openvibe-node resume`) lifts it; the owner's clear does not. The Node keeps refusing motion (`nack`
++`local_stop`) and reports the latch again after the owner's clear, which latches the robot again. Bot keeps
++`local` and `reason` (plain text, at most 120 characters) in `device_estop`, and the panel says the robot is
++stopped on the robot and holds back its Clear button until the device reports the release. After the release
++(`latched: false`) Bot's own latch still waits for the owner: two keys, the robot's and the owner's.
++
### Examples
Pair (unauthenticated socket):
ddiff --git a/public/drive.js b/public/drive.js
nnew file mode 100644
iindex 0000000..2fce994
---- /dev/null
++++ b/public/drive.js
@@@ -0,0 +1,28 @@
++'use strict';
++
++/**
++ * The Drive popup (/panel/:id/drive, server/web/render.js renderDrivePage). It is first-party, so it has the
++ * openvibe.bot session that a panel framed on another site cannot see: it mints a one-time control ticket
++ * (POST /panel/:id/ticket) and posts it to the panel that opened it, on this origin only, then closes. Signing in on
++ * the way (OpenVibe.Network) cuts the link to the opener, so then it says to press Drive again.
++ */
++(() => {
++ const pageEl = document.querySelector('[data-drive-page]');
++ const statusEl = document.querySelector('[data-drive-status]');
++ const say = (text) => { if (statusEl) statusEl.textContent = text; };
++ const robotId = pageEl && pageEl.dataset.robotId;
++ if (!robotId) return;
++ if (!window.opener) {
++ say('You are signed in. Go back to the stream and press Drive again.');
++ return;
++ }
++ fetch(`/panel/${encodeURIComponent(robotId)}/ticket`, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: '{}' })
++ .then((r) => r.json().catch(() => ({})).then((body) => ({ ok: r.ok, body })))
++ .then(({ ok, body }) => {
++ if (!ok || !body.ticket) { say(body.detail || 'This robot cannot be driven from here.'); return; }
++ window.opener.postMessage({ type: 'ov-bot-ticket', robot_id: robotId, ticket: body.ticket }, location.origin);
++ say('The panel has the controls. This window closes now.');
++ setTimeout(() => window.close(), 400);
++ })
++ .catch(() => say('openvibe.bot did not answer. Close this window and press Drive again.'));
++})();
ddiff --git a/public/panel.css b/public/panel.css
iindex 519a56b..31a558b 100644
---- a/public/panel.css
++++ b/public/panel.css
@@@ -274,3 +274,8 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf
.turn { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem 0.9rem; margin-top: 0.6rem; padding: 0.5rem 0.75rem; border: 1px solid var(--accent); border-radius: 10px; background: color-mix(in srgb, var(--accent) 12%, transparent); }
.turn-state { font-weight: 700; } .turn-meta { color: var(--muted); font-variant-numeric: tabular-nums; } .turn form { margin-left: auto; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
++
++/* The Drive popup (/panel/:id/drive): one card, sized for a small window. */
++.drive-page { max-width: 28rem; }
++.drive-page h1 { margin: 0 0 0.5rem; font-size: 1.2rem; }
++.embed-sign-in { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; }
ddiff --git a/public/panel.js b/public/panel.js
iindex befa000..8683ae0 100644
---- a/public/panel.js
++++ b/public/panel.js
@@@ -22,7 +22,10 @@
* URL is left as the server drew it
*
* A watcher (data-role="watcher": the embed's anonymous, read-only view) joins on /watch instead and only paints
-- * the state it is sent: no command, e-stop or clear frame is ever sent from it.
++ * the state it is sent: no command, e-stop or clear frame is ever sent from it. Its Drive button opens
++ * /panel/:id/drive, a first-party popup with the openvibe.bot session the frame cannot see; the popup posts back a
++ * one-time ticket (same origin only), and the panel re-joins on /control offering it as a subprotocol. Each join
++ * made that way carries the next ticket, so a dropped socket reconnects; when none is left it watches again.
*
* 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.
@@@ -75,7 +78,9 @@
const main = document.getElementById('panel');
if (!main) return;
const ROBOT_ID = main.dataset.robotId;
-- const WATCHER = main.dataset.role === 'watcher';
++ let watcher = main.dataset.role === 'watcher';
++ let ticket = null; // the next control ticket (the embed's Drive), spent by the connect that offers it
++ let driving = false; // joined /control with a ticket
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 STICK_DEADZONE = 0.12; // touch joystick, fraction of its radius
@@@ -99,7 +104,7 @@
}
function raw(frame) {
if (!ws || ws.readyState !== WebSocket.OPEN) return false;
-- if (WATCHER && frame.type !== 'join') return false; // /watch is read-only
++ if (watcher && frame.type !== 'join') return false; // /watch is read-only
ws.send(JSON.stringify({ v: 1, ...frame }));
return true;
}
@@@ -416,7 +421,7 @@
if (PAD_DPAD[b]) keyPress(PAD_DPAD[b], `pad:${b}`);
else if (b === 0) { pad.tone = $$('[data-tone]').find((el) => !el.disabled) || null; if (pad.tone) pressTone(pad.tone); }
else if (b === 1) { stopDrive(); pad.reset(); } // a stick still pushed does not drive on
-- else if (b === 9 && $('[data-estop]')) estop();
++ else if (b === 9 && $('[data-estop]:not([hidden])')) estop();
}
for (const b of pad.down) if (!now.has(b)) {
if (PAD_DPAD[b]) keyRelease(PAD_DPAD[b], `pad:${b}`);
@@@ -651,15 +656,26 @@
function paint(state) {
if (!state) return;
const latched = !!(state.estop && state.estop.latched);
++ // The robot's own stop (openvibe-node stop, or its driver's stop button) holds until it is released on the
++ // robot: clearing here cannot lift it, so the button waits and the note says where to release it.
++ const held = !!(state.device_estop && state.device_estop.latched && state.device_estop.local);
const banner = $('[data-estop-banner]');
-- if (banner) banner.dataset.latched = String(latched);
++ if (banner) banner.dataset.latched = String(latched || held);
const label = $('[data-estop-state]');
-- if (label) label.textContent = latched ? 'E-stop latched' : 'E-stop clear';
++ if (label) label.textContent = held ? 'Stopped on the robot' : latched ? 'E-stop latched' : 'E-stop clear';
const note = $('.estop-note');
-- if (note) note.textContent = latched ? ' — the robot stays still until the owner clears it.' : '';
++ if (note) {
++ const why = held && state.device_estop.reason ? ` (${state.device_estop.reason})` : '';
++ note.textContent = held ? ` — its local stop${why} holds until it is released on the robot (openvibe-node resume); then the owner clears it here.`
++ : latched ? ' — the robot stays still until the owner clears it.' : '';
++ }
const clear = $('[data-estop-clear]');
-- if (clear) clear.hidden = !latched;
-- if (latched) releaseAll();
++ if (clear) {
++ clear.hidden = !latched;
++ clear.disabled = held;
++ clear.title = held ? 'Release the stop on the robot first' : '';
++ }
++ if (latched || held) releaseAll();
setOnline(state.online ? 'online' : 'offline', state.online ? 'Online' : 'Device offline');
main.dataset.deviceOnline = String(!!state.online);
if (state.telemetry) lastTelemetryAt = performance.now();
@@@ -706,6 +722,12 @@
if (m.type === 'joined') {
joined = true; retry = 0;
allowed = m.allowed_commands || [];
++ if (m.ticket) ticket = m.ticket;
++ if (driving) {
++ const label = $('[data-role-label]');
++ if (label && m.role) label.textContent = { owner: 'Owner', operator: 'Operator', viewer: 'Viewer', queue: 'In the queue' }[m.role] || m.role;
++ for (const el of $$('[data-estop]')) el.hidden = !(m.role === 'owner' || m.role === 'operator');
++ }
notice('');
enableControls();
paint(m.state);
@@@ -723,7 +745,16 @@
}
function connect() {
-- ws = new WebSocket(wsUrl(WATCHER ? '/watch' : '/control'));
++ const path = watcher ? '/watch' : '/control';
++ if (driving && ticket) {
++ ws = new WebSocket(wsUrl(path), ['ov.ticket', `ov.ticket.${ticket}`]);
++ ticket = null;
++ } else if (driving) {
++ stopDriving('Press Drive to take the controls again.');
++ ws = new WebSocket(wsUrl('/watch'));
++ } else {
++ ws = new WebSocket(wsUrl(path));
++ }
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) => {
@@@ -732,10 +763,42 @@
joined = false;
enableControls();
setOnline('disconnected', 'Reconnecting…');
++ if (e.code === 4002 && driving) { stopDriving('Press Drive to take the controls again.'); setTimeout(connect, 0); return; }
if (e.code === 4002) { setOnline('disconnected', 'Signed out'); notice('Sign in again to drive this robot.'); return; }
if (!gone) setTimeout(connect, Math.min(10000, 500 * 2 ** retry++));
};
}
++ // ── The embed's Drive ─────────────────────────────────────────────────────────────────────────────
++ function stopDriving(text) {
++ driving = false; watcher = true; ticket = null;
++ for (const el of $$('[data-estop]')) el.hidden = true;
++ const label = $('[data-role-label]');
++ if (label) label.textContent = 'Watching';
++ const bar = $('[data-drive-bar]');
++ if (bar) bar.hidden = false;
++ if (text) notice(text);
++ }
++ let popup = null;
++ for (const btn of $$('[data-drive]')) {
++ btn.addEventListener('click', () => {
++ popup = window.open(`/panel/${encodeURIComponent(ROBOT_ID)}/drive`, 'ov-bot-drive', 'popup,width=440,height=560');
++ if (!popup) window.open(`/panel/${encodeURIComponent(ROBOT_ID)}`, '_blank', 'noopener');
++ });
++ }
++ window.addEventListener('message', (e) => {
++ // Only the popup this panel opened, on this origin, for this robot.
++ if (e.origin !== location.origin || !popup || e.source !== popup) return;
++ const d = e.data || {};
++ if (d.type !== 'ov-bot-ticket' || d.robot_id !== ROBOT_ID || typeof d.ticket !== 'string') return;
++ ticket = d.ticket; driving = true; watcher = false; gone = false;
++ const bar = $('[data-drive-bar]');
++ if (bar) bar.hidden = true;
++ notice('');
++ if (ws) { ws.onclose = null; try { ws.close(); } catch { /* already closed */ } }
++ releaseAll(); joined = false; enableControls();
++ connect();
++ });
++
enableControls();
connect();
})();
ddiff --git a/server/control-tickets.js b/server/control-tickets.js
nnew file mode 100644
iindex 0000000..7d652e5
---- /dev/null
++++ b/server/control-tickets.js
@@@ -0,0 +1,62 @@
++'use strict';
++/**
++ * One-time control tickets: driving a robot from its panel framed on another site (plan T15 R9, the Live channel page).
++ *
++ * The frame cannot see the openvibe.bot session (the cookie is SameSite=Lax, and the frame is cross-site), so it starts
++ * as a read-only watcher. Its Drive button opens /panel/:id/drive in a first-party popup, which has the session, mints a
++ * ticket here (POST /panel/:id/ticket) and hands it to the frame by postMessage, same origin only. The frame then opens
++ * /control offering the ticket as a WebSocket subprotocol ("ov.ticket", "ov.ticket.<ticket>"), so it never appears in a
++ * URL or an access log.
++ *
++ * A ticket is single use, lives TTL_MS and names one robot and one person. A join made with one carries the next
++ * ticket, so a dropped socket reconnects without asking again; an unused one simply expires. Every permission is still
++ * decided at the join, by the person's role on that robot, exactly as on openvibe.bot itself.
++ */
++const crypto = require('crypto');
++
++const TTL_MS = 120_000;
++const MAX_TICKETS = 10_000;
++const PROTOCOL = 'ov.ticket';
++const TICKET_RE = /^[A-Za-z0-9_-]{43}$/;
++
++function createControlTickets({ now = () => Date.now(), ttlMs = TTL_MS } = {}) {
++ const tickets = new Map(); // ticket → { principal, robotId, exp }
++
++ function sweep() {
++ const t = now();
++ for (const [k, v] of tickets) if (v.exp <= t) tickets.delete(k);
++ }
++
++ /** A new ticket for `principal` (a user principal) on `robotId`, or null when the store is full. */
++ function issue(principal, robotId) {
++ if (!principal || principal.kind !== 'user' || !principal.subject || !robotId) return null;
++ if (tickets.size >= MAX_TICKETS) sweep();
++ if (tickets.size >= MAX_TICKETS) return null;
++ const ticket = crypto.randomBytes(32).toString('base64url');
++ const { ticketRobot, ...who } = principal; // a principal made from a ticket never carries it into the next
++ tickets.set(ticket, { principal: who, robotId: String(robotId), exp: now() + ttlMs });
++ return { ticket, expires_in: Math.round(ttlMs / 1000) };
++ }
++
++ /** The ticket's { principal, robotId }, once; null when unknown, used or expired. */
++ function redeem(ticket) {
++ const key = String(ticket || '');
++ const t = tickets.get(key);
++ if (!t) return null;
++ tickets.delete(key);
++ return t.exp > now() ? t : null;
++ }
++
++ return { issue, redeem, sweep, size: () => tickets.size };
++}
++
++/** The ticket a /control upgrade offers in Sec-WebSocket-Protocol ("ov.ticket, ov.ticket.<ticket>"), or null. */
++function ticketFromProtocols(header) {
++ const offered = String(header || '').split(',').map((s) => s.trim()).filter(Boolean);
++ if (!offered.includes(PROTOCOL)) return null;
++ const named = offered.find((p) => p.startsWith(`${PROTOCOL}.`));
++ const value = named ? named.slice(PROTOCOL.length + 1) : '';
++ return TICKET_RE.test(value) ? value : null;
++}
++
++module.exports = { createControlTickets, ticketFromProtocols, PROTOCOL, TTL_MS };
ddiff --git a/server/realtime.js b/server/realtime.js
iindex 698a8dd..da0dc14 100644
---- a/server/realtime.js
++++ b/server/realtime.js
@@@ -22,6 +22,7 @@
* first result instead of reaching the device again.
*/
const { WebSocketServer } = require('ws');
++const { createControlTickets, ticketFromProtocols, PROTOCOL: TICKET_PROTOCOL } = require('./control-tickets');
const { capabilities, ids } = require('openvibe-contracts');
const { json, iso, prefixedId } = require('./util');
const { userPrincipal, verifyService, verifyNode, isNodeToken, PRINCIPAL_SUB, decodePayload } = require('./api/auth');
@@@ -47,6 +48,7 @@ const isSubject = (v) => typeof v === 'string' && ids.isSubjectId('user', v);
function createRealtime({ config, keys, userAuth, log = console, now = () => Date.now() }) {
let domain = null;
++ const tickets = createControlTickets({ now }); // the embedded panel's Drive (server/control-tickets.js)
const deviceConns = new Map(); // device_id → conn
const subsByRobot = new Map(); // robot_id → Set<conn>
const pendingCmds = new Map(); // device-facing command id (server-minted) → pending
@@@ -56,7 +58,10 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
let closed = false;
const deviceWss = new WebSocketServer({ noServer: true, maxPayload: 64 * 1024, perMessageDeflate: false });
-- const controlWss = new WebSocketServer({ noServer: true, maxPayload: 64 * 1024, perMessageDeflate: false });
++ // A socket that offers a control ticket gets the ticket protocol back (a browser drops a socket whose offered
++ // subprotocol is not answered); a plain one, none.
++ const controlWss = new WebSocketServer({ noServer: true, maxPayload: 64 * 1024, perMessageDeflate: false,
++ handleProtocols: (protocols) => (protocols.has(TICKET_PROTOCOL) ? TICKET_PROTOCOL : 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
@@@ -358,8 +363,12 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
await domain.audit.record({ robotId: String(msg.robot_id).slice(0, 64), deviceId: conn.device.id, operatorKind: 'device', kind: 'estop_state', value: { latched }, result: 'refused', reason: 'bot.forbidden' });
return sendError(conn, 'bot.forbidden', 'this device is not attached to that robot');
}
++ // local_stop: the latch is held on the machine itself (openvibe-node stop, a driver's own stop button), which
++ // only a release on the machine lifts; the owner's clear does not. Shown on the panel, never trusted for more.
++ const local = latched && msg.local_stop === true;
++ const reason = local && typeof msg.reason === 'string' ? msg.reason.replace(/[\u0000-\u001f\u007f]/g, ' ').trim().slice(0, 120) || null : null;
for (const robotId of msg.robot_id != null ? [msg.robot_id] : attached) {
-- conn.deviceEstop.set(robotId, { latched, at: iso(now()) });
++ conn.deviceEstop.set(robotId, { latched, local, reason, at: iso(now()) });
if (latched) {
const robot = await domain.robots.get(robotId);
if (robot && !robot.estop_latched) {
@@@ -376,7 +385,12 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
const cookie = parseCookies(req.headers.cookie)[ACCESS_COOKIE];
if (cookie) { const claims = await userAuth.verify(cookie); const p = userPrincipal(claims); if (p) return p; }
const token = bearer(req);
-- if (!token) return null;
++ if (!token) {
++ // The embedded panel's Drive: a one-time ticket, redeemed here and bound to one robot (checked at the join).
++ const offered = ticketFromProtocols(req.headers['sec-websocket-protocol']);
++ const redeemed = offered ? tickets.redeem(offered) : null;
++ return redeemed ? { ...redeemed.principal, ticketRobot: redeemed.robotId } : null;
++ }
const payload = decodePayload(token);
if (payload && typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) {
const r = await verifyService(token, tokenOpts());
@@@ -391,7 +405,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
function onControlSocket(ws, req, principal) {
if (!principal) return ws.close(4002, 'sign in required');
-- const conn = { ws, seq: 0, principal, subject: principal.subject, robotId: null, role: null };
++ const conn = { ws, seq: 0, principal, subject: principal.subject, robotId: null, role: null, ticketRobot: principal.ticketRobot || null };
ws.conn = conn;
ws.on('message', (raw) => handleControlMessage(conn, raw).catch((e) => { log.warn(`[Bot] control: ${e.message}`); sendError(conn, e.code || 'bot.internal', e.detail || e.message); }));
ws.on('close', () => unsubscribe(conn));
@@@ -416,6 +430,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
async function onJoin(conn, msg) {
const robotId = String(msg.robot_id || '');
++ if (conn.ticketRobot && robotId !== conn.ticketRobot) return sendError(conn, 'bot.forbidden', 'this ticket is for another robot');
const robot = await domain.robots.get(robotId);
if (!robot) return sendError(conn, 'bot.robot_not_found', 'no such robot');
let role = await domain.members.roleOf(robotId, conn.subject);
@@@ -426,10 +441,13 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
subscribe(conn);
const profileRow = await getProfile(domain.db, robot.profile_id, robot.profile_version);
const profile = profileRow ? profileRow.profile : null;
++ // A socket opened with a ticket gets the next one, for its reconnect (the one it used is spent).
++ const next = conn.ticketRobot ? tickets.issue(conn.principal, robotId) : null;
sendFrame(conn, 'joined', {
robot: domain.present.robot(robot), role, profile,
allowed_commands: domain.control.allowedFor(robot, role, profile),
state: await robotState(robotId, conn.subject),
++ ...(next ? { ticket: next.ticket } : {}),
});
}
@@@ -728,6 +746,8 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
bindDomain(d) { domain = d; },
bindJobs(j) { jobs = j; },
handleUpgrade, attachSim, detachSim,
++ /** A one-time ticket for the embedded panel's Drive (POST /panel/:id/ticket), or null. */
++ issueControlTicket(principal, robotId) { return tickets.issue(principal, robotId); },
isOnline, deviceState, sendToDevice, sendToRobotDevices, broadcast, closeDevice, robotState, refreshConfig, pushEstop, closeWatchers, dropSubject, operatorCommand,
// 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; },
ddiff --git a/server/web/render.js b/server/web/render.js
iindex b0c5a21..b6962dd 100644
---- a/server/web/render.js
++++ b/server/web/render.js
@@@ -351,7 +351,9 @@ function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs
const widgets = (profile.widgets || []).map((w, i) => ({ w, i }))
.sort((a, b) => (a.w.order != null ? a.w.order : 1e9 + a.i) - (b.w.order != null ? b.w.order : 1e9 + b.i))
.map(({ w }) => renderWidget(w, { profile, allowed_commands, signaling_url }));
-- const estop = ESTOP_ROLES.has(role) ? `<button type="button" class="estop" data-estop>${icon('stop')}<span>E-stop</span></button>` : '';
++ // A framed watcher may take the controls with Drive; its e-stop waits, hidden, for an owner's or operator's join.
++ const estop = ESTOP_ROLES.has(role) ? `<button type="button" class="estop" data-estop>${icon('stop')}<span>E-stop</span></button>`
++ : (embed && role === 'watcher' ? `<button type="button" class="estop" data-estop hidden>${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}">
${embed ? '' : topbar(`<span class="crumb">${esc(robot.name)}</span>`)}
@@@ -363,7 +365,7 @@ ${embed ? '' : topbar(`<span class="crumb">${esc(robot.name)}</span>`)}
<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>
${turnStrip(robot, role)}
</div>
--${embed && role === 'watcher' && !signedIn ? `<p class="embed-sign-in">${outLink(`/panel/${robot.id}`, 'Sign in to control')}</p>` : ''}
++${embed && role === 'watcher' && !signedIn ? `<p class="embed-sign-in" data-drive-bar><button type="button" class="button primary" data-drive>${icon('gamepad')}<span>Drive</span></button> ${outLink(`/panel/${robot.id}`, 'Open on openvibe.bot')}</p>` : ''}
<p class="notice" data-notice role="alert" hidden></p>
<div class="widgets">
${widgets.join('\n')}
@@@ -373,6 +375,19 @@ ${role === 'owner' && !embed ? `<div class="owner-settings">${localProfileForm(r
return page(robot.name, body, { scripts: ['/panel/panel.js'], bodyClass: embed ? 'embed-page' : 'panel-page' });
}
++/**
++ * The Drive popup (/panel/:id/drive): first-party, so it has the openvibe.bot session the frame cannot see. Its script
++ * (public/drive.js) mints a one-time control ticket and hands it to the panel that opened it, then closes.
++ */
++function renderDrivePage({ robot }) {
++ const body = `<main class="page drive-page" data-drive-page data-robot-id="${esc(robot.id)}">
++<div class="card"><h1>${esc(robot.name)}</h1>
++<p data-drive-status role="status">Connecting the panel…</p>
++<p>${outLink(`/panel/${robot.id}`, 'Open the full panel instead')}</p></div>
++</main>`;
++ return page(`Drive ${robot.name}`, body, { scripts: ['/panel/drive.js'], bodyClass: 'drive-popup' });
++}
++
/** 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">
@@@ -441,4 +456,4 @@ function renderPairingPage({ robot, pairing, profile = null }) {
return page(`Pair ${robot.name}`, body, { scripts: ['/panel/panel.js'] });
}
--module.exports = { page, renderPanel, renderEmbedRefused, renderWidget, renderRobotsPage, renderPairingPage, camerasOf, esc };
++module.exports = { page, renderPanel, renderEmbedRefused, renderDrivePage, renderWidget, renderRobotsPage, renderPairingPage, camerasOf, esc };
ddiff --git a/server/web/routes.js b/server/web/routes.js
iindex ca86834..73aa543 100644
---- a/server/web/routes.js
++++ b/server/web/routes.js
@@@ -36,7 +36,7 @@ const { getProfile, listProfiles } = require('../profiles');
const { frameAncestors } = require('../config');
const { BotError } = require('../util');
const kits = require('../kits');
--const { renderPanel, renderEmbedRefused, renderRobotsPage, renderPairingPage } = require('./render');
++const { renderPanel, renderEmbedRefused, renderDrivePage, renderRobotsPage, renderPairingPage } = require('./render');
const { renderDocsIndex, renderDriversPage, renderProfilesPage } = require('./docs');
const { checkProfile } = require('../profiles');
const { renderHome, HOME_CSP } = require('./home');
@@@ -84,6 +84,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
const asset = (file, type) => (req, res) => { res.setHeader('Cache-Control', 'no-cache'); res.type(type).sendFile(path.join(PUBLIC, file)); };
r.get('/panel/panel.js', asset('panel.js', 'application/javascript'));
r.get('/panel/panel.css', asset('panel.css', 'text/css'));
++ r.get('/panel/drive.js', asset('drive.js', 'application/javascript'));
const requireUser = (req) => {
if (!req.viewer) throw new BotError(401, 'bot.sign_in', 'sign in first');
@@@ -409,6 +410,35 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
await sendPanel(res, robot, profile, role, 'page', true, { catalogueId: local ? local.source_profile_id : robot.profile_id });
}));
++ // The embed's Drive (server/control-tickets.js): the popup page, first-party, signs in if it must and then mints a
++ // ticket for the panel that opened it. Someone with no way onto the robot is told so here, not at the socket.
++ r.get('/panel/:id/drive', 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');
++ if (!await memberRole(robot, me)) throw new BotError(403, 'bot.not_an_operator', 'you have no access to drive this robot');
++ res.type('html').send(renderDrivePage({ robot: domain.present.robot(robot) }));
++ }));
++ // A fetch from the popup itself only: the session cookie is SameSite=Lax, and the origin is checked as well.
++ r.post('/panel/:id/ticket', ...manage, async (req, res, next) => {
++ res.setHeader('Cache-Control', 'no-store');
++ try {
++ const site = req.headers['sec-fetch-site'];
++ if (!sameOrigin(req) || (site && site !== 'same-origin')) throw new BotError(403, 'bot.forbidden', 'a ticket is minted only by openvibe.bot itself');
++ 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');
++ if (!await memberRole(robot, me)) throw new BotError(403, 'bot.not_an_operator', 'you have no access to drive this robot');
++ const t = hub && hub.issueControlTicket(me, robot.id);
++ if (!t) throw new BotError(503, 'bot.busy', 'try again in a moment');
++ res.json({ ticket: t.ticket, robot_id: robot.id, expires_in: t.expires_in });
++ } catch (e) {
++ if (e.status && e.code) return res.status(e.status).json({ code: e.code, detail: e.detail || e.code });
++ return next(e);
++ }
++ return undefined;
++ });
++
// 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', embedCsp(config));
ddiff --git a/test/device-safety.test.js b/test/device-safety.test.js
iindex 1ee1405..28fce45 100644
---- a/test/device-safety.test.js
++++ b/test/device-safety.test.js
@@@ -54,6 +54,30 @@ const { boot, check, done } = require('./helpers/app');
op.close(); dev.close();
});
++ await check('a local stop on the device (openvibe-node stop) is shown as one, and the owner clear cannot lift it', async () => {
++ const { robot, dev } = await connected();
++ const op = await ownerJoined(robot.id);
++ dev.send({ type: 'estop_state', latched: true, local_stop: true, reason: 'stop\u0007 button\n on the robot', robot_id: robot.id });
++ const st = await op.waitFor((m) => m.type === 'robot_state' && m.state.device_estop && m.state.device_estop.local === true);
++ assert.strictEqual(st.state.estop.latched, true, 'the device latched the robot');
++ assert.strictEqual(st.state.device_estop.reason, 'stop button on the robot', 'the reason is shown as plain, short text');
++ // The owner clears Bot's latch; the device still holds its own and says so again, which latches the robot again.
++ op.send({ type: 'estop_clear' });
++ await dev.waitFor((m) => m.type === 'estop' && m.latched === false);
++ op.messages.length = 0; // waitFor also searches frames already received: only the ones from here on count
++ dev.send({ type: 'estop_state', latched: true, local_stop: true, robot_id: robot.id });
++ await op.waitFor((m) => m.type === 'robot_state' && m.state.estop.latched === true && m.state.device_estop.local === true);
++ // Released on the robot: the report is latched:false, never local, and Bot's latch waits for the owner.
++ op.messages.length = 0;
++ dev.send({ type: 'estop_state', latched: false, local_stop: true, robot_id: robot.id });
++ const after = await op.waitFor((m) => m.type === 'robot_state' && m.state.device_estop && m.state.device_estop.latched === false);
++ assert.deepStrictEqual([after.state.device_estop.local, after.state.device_estop.reason, after.state.estop.latched], [false, null, true]);
++ const panel = require('fs').readFileSync(require('path').join(__dirname, '..', 'public', 'panel.js'), 'utf8');
++ assert.match(panel, /state\.device_estop\.local/, 'the panel reads the local flag');
++ assert.match(panel, /clear\.disabled = held;/, 'and waits with the clear button while the robot holds its stop');
++ op.close(); dev.close();
++ });
++
await check('a device cannot latch or report for a robot it is not attached to (bot.forbidden, audited)', async () => {
const { dev, deviceId } = await connected();
const { robot: other } = await t.robot(alex);
ddiff --git a/test/embed-drive.test.js b/test/embed-drive.test.js
nnew file mode 100644
iindex 0000000..aa38595
---- /dev/null
++++ b/test/embed-drive.test.js
@@@ -0,0 +1,124 @@
++'use strict';
++// Driving from the embedded panel (plan T15 R9: a Live channel shows its robot's panel under the stream). The frame
++// cannot see the openvibe.bot session (SameSite=Lax), so Drive opens a first-party popup that mints a one-time ticket
++// (POST /panel/:id/ticket) and posts it to the frame; the frame opens /control offering it as a subprotocol.
++const fs = require('fs');
++const path = require('path');
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/app');
++const { createControlTickets, ticketFromProtocols } = require('../server/control-tickets');
++const { renderPanel } = require('../server/web/render');
++
++(async () => {
++ await check('tickets: single use, expire, never carry the ticket robot into the next; the protocol header parses strictly', () => {
++ let at = 1000;
++ const tk = createControlTickets({ now: () => at, ttlMs: 60_000 });
++ const me = { kind: 'user', subject: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ', username: 'alex', ticketRobot: 'rob_old' };
++ const a = tk.issue(me, 'rob_1');
++ assert.match(a.ticket, /^[A-Za-z0-9_-]{43}$/);
++ assert.strictEqual(a.expires_in, 60);
++ const r = tk.redeem(a.ticket);
++ assert.strictEqual(r.robotId, 'rob_1');
++ assert.strictEqual(r.principal.subject, me.subject);
++ assert.strictEqual(r.principal.ticketRobot, undefined, 'a ticket-made principal does not carry the old binding');
++ assert.strictEqual(tk.redeem(a.ticket), null, 'single use');
++ const b = tk.issue(me, 'rob_1');
++ at += 60_001;
++ assert.strictEqual(tk.redeem(b.ticket), null, 'expired');
++ assert.strictEqual(tk.issue({ kind: 'service', subject: me.subject }, 'rob_1'), null, 'people only');
++ assert.strictEqual(ticketFromProtocols(`ov.ticket, ov.ticket.${a.ticket}`), a.ticket);
++ assert.strictEqual(ticketFromProtocols(`ov.ticket.${a.ticket}`), null, 'the marker protocol is required');
++ assert.strictEqual(ticketFromProtocols('ov.ticket, ov.ticket.short'), null);
++ assert.strictEqual(ticketFromProtocols(''), null);
++ });
++
++ await check('the framed watcher gets a Drive button and a hidden e-stop; the client takes tickets from its own popup only', () => {
++ const SIM = { id: 'sim.rover', name: 'Rover', commands: { drive: { axes: { throttle: [-1, 1], steer: [-1, 1] } } }, widgets: [{ type: 'drive', label: 'Drive', command: { kind: 'drive' } }] };
++ const html = renderPanel({ robot: { id: 'rob_d1', name: 'Rover', profile_id: 'sim.rover', estop: { latched: false } }, profile: SIM, role: 'watcher', allowed_commands: [], mode: 'embed', signedIn: false });
++ assert.ok(html.includes('data-drive>') && html.includes('data-drive-bar'));
++ assert.ok(html.includes('data-estop hidden>'), 'the e-stop waits for an owner or operator join');
++ const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'panel.js'), 'utf8');
++ assert.match(js, /if \(e\.origin !== location\.origin \|\| !popup \|\| e\.source !== popup\) return;/);
++ assert.match(js, /new WebSocket\(wsUrl\(path\), \['ov\.ticket', `ov\.ticket\.\$\{ticket\}`\]\)/);
++ assert.match(js, /\$\('\[data-estop\]:not\(\[hidden\]\)'\)\) estop\(\);/, 'a gamepad never e-stops through a hidden button');
++ const popupJs = fs.readFileSync(path.join(__dirname, '..', 'public', 'drive.js'), 'utf8');
++ assert.match(popupJs, /window\.opener\.postMessage\(\{ type: 'ov-bot-ticket', robot_id: robotId, ticket: body\.ticket \}, location\.origin\)/);
++ });
++
++ const t = await boot();
++ const alex = t.network.newUser('alex');
++ const bob = t.network.newUser('bob');
++ const cookie = (user) => `ov_token=${t.network.signUser(user)}`;
++ const mint = (robotId, user, headers = {}) => fetch(`${t.base}/panel/${robotId}/ticket`, {
++ method: 'POST', redirect: 'manual',
++ headers: { 'Content-Type': 'application/json', 'Sec-Fetch-Site': 'same-origin', ...(user ? { Cookie: cookie(user) } : {}), ...headers },
++ body: '{}',
++ });
++ const withTicket = (ticket) => t.ws('/control', { protocols: ['ov.ticket', `ov.ticket.${ticket}`] });
++
++ const { robot: mine } = await t.robot(alex, { name: 'Desk rover' });
++ const { robot: other } = await t.robot(alex, { name: 'Other rover' });
++ const { robot: open } = await t.robot(alex, { name: 'Queue rover', access_policy: 'queue' });
++
++ await check('POST /panel/:id/ticket: a member gets a ticket; cross-site, signed-out and no-access calls are refused', async () => {
++ const r = await mint(mine.id, alex);
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(r.headers.get('cache-control'), 'no-store');
++ const j = await r.json();
++ assert.match(j.ticket, /^[A-Za-z0-9_-]{43}$/);
++ assert.strictEqual(j.robot_id, mine.id);
++ assert.strictEqual((await mint(mine.id, alex, { 'Sec-Fetch-Site': 'cross-site' })).status, 403);
++ assert.strictEqual((await mint(mine.id, alex, { Origin: 'https://evil.example' })).status, 403);
++ assert.strictEqual((await mint(mine.id, null)).status, 401);
++ assert.strictEqual((await mint(mine.id, bob)).status, 403, 'a stranger on a private robot');
++ assert.strictEqual((await mint(open.id, bob)).status, 200, 'anyone signed in may queue for a queue robot');
++ assert.strictEqual((await mint('rob_nothing', alex)).status, 404);
++ });
++
++ await check('GET /panel/:id/drive: the popup page for a member; sign in first otherwise', async () => {
++ const page = await fetch(`${t.base}/panel/${mine.id}/drive`, { redirect: 'manual', headers: { Cookie: cookie(alex) } });
++ assert.strictEqual(page.status, 200);
++ const html = await page.text();
++ assert.ok(html.includes('data-drive-page') && html.includes(`data-robot-id="${mine.id}"`) && html.includes('/panel/drive.js'));
++ const out = await fetch(`${t.base}/panel/${mine.id}/drive`, { redirect: 'manual' });
++ assert.strictEqual(out.status, 302);
++ assert.match(out.headers.get('location'), /^\/auth\/login\?next=/);
++ assert.strictEqual((await fetch(`${t.base}/panel/${mine.id}/drive`, { redirect: 'manual', headers: { Cookie: cookie(bob) } })).status, 403);
++ assert.strictEqual((await fetch(`${t.base}/panel/drive.js`)).status, 200);
++ });
++
++ await check('/control with a ticket: the person\'s own role, the next ticket on join, one robot only, and spent tickets refused', async () => {
++ const { ticket } = await (await mint(mine.id, alex)).json();
++ const op = await withTicket(ticket);
++ assert.strictEqual(op.ws.protocol, 'ov.ticket', 'the server answers the ticket protocol');
++ op.send({ type: 'join', robot_id: mine.id });
++ const joined = await op.waitFor((m) => m.type === 'joined');
++ assert.strictEqual(joined.role, 'owner');
++ assert.ok(joined.allowed_commands.includes('drive'));
++ assert.match(joined.ticket, /^[A-Za-z0-9_-]{43}$/, 'the reconnect ticket');
++ assert.notStrictEqual(joined.ticket, ticket);
++ op.send({ type: 'join', robot_id: other.id });
++ const refused = await op.waitFor((m) => m.type === 'error');
++ assert.strictEqual(refused.code, 'bot.forbidden', 'a ticket is for one robot');
++ op.close();
++ // The spent ticket opens nothing; the reconnect ticket does, once.
++ const spent = await withTicket(ticket);
++ assert.strictEqual(await spent.waitForClose(), 4002);
++ const again = await withTicket(joined.ticket);
++ again.send({ type: 'join', robot_id: mine.id });
++ assert.strictEqual((await again.waitFor((m) => m.type === 'joined')).role, 'owner');
++ again.close();
++ const twice = await withTicket(joined.ticket);
++ assert.strictEqual(await twice.waitForClose(), 4002);
++ });
++
++ await check('a queue robot: a stranger\'s ticket joins the queue like the page does', async () => {
++ const { ticket } = await (await mint(open.id, bob)).json();
++ const op = await withTicket(ticket);
++ op.send({ type: 'join', robot_id: open.id });
++ assert.strictEqual((await op.waitFor((m) => m.type === 'joined')).role, 'queue');
++ op.close();
++ });
++
++ await done(t);
++})();
ddiff --git a/test/embed-panel.test.js b/test/embed-panel.test.js
iindex c29f9be..6854d78 100644
---- a/test/embed-panel.test.js
++++ b/test/embed-panel.test.js
@@@ -24,10 +24,10 @@ const { renderPanel } = require('../server/web/render');
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>/);
++ assert.match(watcher, /<a class="button" href="\/panel\/rob_e1" target="_blank" rel="noopener">Open on openvibe.bot<\/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'));
++ assert.ok(!signedIn.includes('Open on openvibe.bot'));
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'] });
@@@ -36,9 +36,9 @@ const { renderPanel } = require('../server/web/render');
await check('the panel client: a watcher joins on /watch and never sends anything but join; a camera tile plays only its URL', () => {
const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'panel.js'), 'utf8');
-- assert.match(js, /const WATCHER = main\.dataset\.role === 'watcher';/);
-- assert.match(js, /new WebSocket\(wsUrl\(WATCHER \? '\/watch' : '\/control'\)\)/);
-- assert.match(js, /if \(WATCHER && frame\.type !== 'join'\) return false;/);
++ assert.match(js, /let watcher = main\.dataset\.role === 'watcher';/);
++ assert.match(js, /const path = watcher \? '\/watch' : '\/control';/);
++ assert.match(js, /if \(watcher && frame\.type !== 'join'\) return false;/);
// The live camera reads the server's attribute, the pinned mediasoup-client build Live loads, and
// the sfu-viewer-* flow; a page with no tile carrying it never imports anything.
assert.match(js, /for \(const tile of \$\$\('\[data-signaling-url\]'\)\) liveCamera\(tile\);/);
@@@ -70,7 +70,7 @@ const { renderPanel } = require('../server/web/render');
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(html.includes('Open on openvibe.bot') && !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';/);
@@@ -90,9 +90,9 @@ const { renderPanel } = require('../server/web/render');
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'));
++ assert.ok(oh.includes('data-role="owner"') && oh.includes('data-estop>') && !oh.includes('data-embed-form') && !oh.includes('Open on openvibe.bot'));
const stranger = await (await get(`/panel/${open.id}/embed`, bob)).text();
-- assert.ok(stranger.includes('data-role="watcher"') && !stranger.includes('Sign in to control'));
++ assert.ok(stranger.includes('data-role="watcher"') && !stranger.includes('Open on openvibe.bot'));
});
await check('a live session\'s signaling URL reaches the anonymous public embed; a private robot is refused before it', async () => {
ddiff --git a/test/helpers/app.js b/test/helpers/app.js
iindex 9d34bea..404b27f 100644
---- a/test/helpers/app.js
++++ b/test/helpers/app.js
@@@ -85,9 +85,10 @@ async function boot(opts = {}) {
const SOCKET_WAIT_MS = 20000;
/** A WebSocket client to /device or /control. waitFor(pred) resolves the first matching frame. */
-- function ws(p, { headers = {} } = {}) {
++ function ws(p, { headers = {}, protocols = null } = {}) {
return new Promise((resolve, reject) => {
-- const socket = new WebSocket(`${base.replace('http', 'ws')}${p}`, { headers });
++ const url = `${base.replace('http', 'ws')}${p}`;
++ const socket = protocols ? new WebSocket(url, protocols, { headers }) : new WebSocket(url, { headers });
const client = { ws: socket, messages: [], ended: false, waiters: [], closeCode: null };
const timer = setTimeout(() => reject(new Error(`ws open timeout on ${p}`)), SOCKET_WAIT_MS);
socket.on('message', (raw) => {