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)
Changes
@@@ -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 thekinds 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.++### ExamplesPair (unauthenticated socket):nnew file mode 100644@@@ -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.'));++})();@@@ -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; }@@@ -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 ticketconst 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@@@ -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-onlyws.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();})();nnew file mode 100644@@@ -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 };@@@ -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 → connconst 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 = () => Datlet 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 notconst watchersByAddr = new Map(); // client address → open /watch sockets@@@ -358,8 +363,12 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Datawait 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 = () => Datconst 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 = () => Datfunction 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 = () => Datasync 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 = () => Datsubscribe(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 = () => DatbindDomain(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; },@@@ -351,7 +351,9 @@ function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMsconst 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(rreturn 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 };@@@ -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, limiconst 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, limiawait 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));@@@ -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);nnew file mode 100644@@@ -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);++})();@@@ -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 () => {@@@ -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) => {
@@ -86,7 +86,7 @@ At most 64 frames wait; any more are answered `error` `bot.not_ready`.
@@ -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 |
| `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) |
| `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 |
| `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
`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.
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
@@ -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`
`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.
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
### Examples
Pair (unauthenticated socket):
Pair (unauthenticated socket):
new file mode 100644
new file mode 100644
@@ -0,0 +1,28 @@
@@ -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.'));
+})();
@@ -274,3 +274,8 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf
@@ -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 { 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; }
.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; }
.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; }
@@ -22,7 +22,10 @@
@@ -22,7 +22,10 @@
* URL is left as the server drew it
* URL is left as the server drew it
*
*
* A watcher (data-role="watcher": the embed's anonymous, read-only view) joins on /watch instead and only paints
* A watcher (data-role="watcher": the embed's anonymous, read-only view) joins on /watch instead and only paints
- * the state it is sent: no command, e-stop or clear frame is ever sent from it.
+ * the state it is sent: no command, e-stop or clear frame is ever sent from it. 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
* 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.
@@ -75,7 +78,9 @@
@@ -75,7 +78,9 @@
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';
+ 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 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
@@ -99,7 +104,7 @@
@@ -99,7 +104,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
+ 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;
}
}
@@ -416,7 +421,7 @@
@@ -416,7 +421,7 @@
if (PAD_DPAD[b]) keyPress(PAD_DPAD[b], `pad:${b}`);
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 === 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 === 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)) {
for (const b of pad.down) if (!now.has(b)) {
if (PAD_DPAD[b]) keyRelease(PAD_DPAD[b], `pad:${b}`);
if (PAD_DPAD[b]) keyRelease(PAD_DPAD[b], `pad:${b}`);
@@ -651,15 +656,26 @@
@@ -651,15 +656,26 @@
function paint(state) {
function paint(state) {
if (!state) return;
if (!state) return;
const latched = !!(state.estop && state.estop.latched);
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]');
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]');
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');
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]');
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');
setOnline(state.online ? 'online' : 'offline', state.online ? 'Online' : 'Device offline');
main.dataset.deviceOnline = String(!!state.online);
main.dataset.deviceOnline = String(!!state.online);
if (state.telemetry) lastTelemetryAt = performance.now();
if (state.telemetry) lastTelemetryAt = performance.now();
@@ -706,6 +722,12 @@
@@ -706,6 +722,12 @@
if (m.type === 'joined') {
if (m.type === 'joined') {
joined = true; retry = 0;
joined = true; retry = 0;
allowed = m.allowed_commands || [];
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('');
notice('');
enableControls();
enableControls();
paint(m.state);
paint(m.state);
@@ -723,7 +745,16 @@
@@ -723,7 +745,16 @@
}
}
function connect() {
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.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) => {
@@ -732,10 +763,42 @@
@@ -732,10 +763,42 @@
joined = false;
joined = false;
enableControls();
enableControls();
setOnline('disconnected', 'Reconnecting…');
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 (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++));
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();
enableControls();
connect();
connect();
})();
})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,62 @@
@@ -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 };
@@ -22,6 +22,7 @@
@@ -22,6 +22,7 @@
* first result instead of reaching the device again.
* first result instead of reaching the device again.
*/
*/
const { WebSocketServer } = require('ws');
const { WebSocketServer } = require('ws');
+const { createControlTickets, ticketFromProtocols, PROTOCOL: TICKET_PROTOCOL } = require('./control-tickets');
const { capabilities, ids } = require('openvibe-contracts');
const { capabilities, ids } = require('openvibe-contracts');
const { json, iso, prefixedId } = require('./util');
const { json, iso, prefixedId } = require('./util');
const { userPrincipal, verifyService, verifyNode, isNodeToken, PRINCIPAL_SUB, decodePayload } = require('./api/auth');
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);
@@ -47,6 +48,7 @@ const isSubject = (v) => typeof v === 'string' && ids.isSubjectId('user', v);
function createRealtime({ config, keys, userAuth, log = console, now = () => Date.now() }) {
function createRealtime({ config, keys, userAuth, log = console, now = () => Date.now() }) {
let domain = null;
let domain = null;
+ const tickets = createControlTickets({ now }); // the embedded panel's Drive (server/control-tickets.js)
const deviceConns = new Map(); // device_id → conn
const deviceConns = new Map(); // device_id → conn
const subsByRobot = new Map(); // robot_id → Set<conn>
const subsByRobot = new Map(); // robot_id → Set<conn>
const pendingCmds = new Map(); // device-facing command id (server-minted) → pending
const pendingCmds = new Map(); // device-facing command id (server-minted) → pending
@@ -56,7 +58,10 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -56,7 +58,10 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
let closed = false;
let closed = false;
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 });
+ // 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 watchWss = new WebSocketServer({ noServer: true, maxPayload: WATCH_MAX_PAYLOAD, perMessageDeflate: false });
const watchConns = new Set(); // every /watch socket, joined or not
const watchConns = new Set(); // every /watch socket, joined or not
const watchersByAddr = new Map(); // client address → open /watch sockets
const watchersByAddr = new Map(); // client address → open /watch sockets
@@ -358,8 +363,12 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -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' });
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');
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) {
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) {
if (latched) {
const robot = await domain.robots.get(robotId);
const robot = await domain.robots.get(robotId);
if (robot && !robot.estop_latched) {
if (robot && !robot.estop_latched) {
@@ -376,7 +385,12 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -376,7 +385,12 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
const cookie = parseCookies(req.headers.cookie)[ACCESS_COOKIE];
const cookie = parseCookies(req.headers.cookie)[ACCESS_COOKIE];
if (cookie) { const claims = await userAuth.verify(cookie); const p = userPrincipal(claims); if (p) return p; }
if (cookie) { const claims = await userAuth.verify(cookie); const p = userPrincipal(claims); if (p) return p; }
const token = bearer(req);
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);
const payload = decodePayload(token);
if (payload && typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) {
if (payload && typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) {
const r = await verifyService(token, tokenOpts());
const r = await verifyService(token, tokenOpts());
@@ -391,7 +405,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -391,7 +405,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
function onControlSocket(ws, req, principal) {
function onControlSocket(ws, req, principal) {
if (!principal) return ws.close(4002, 'sign in required');
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.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('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));
ws.on('close', () => unsubscribe(conn));
@@ -416,6 +430,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -416,6 +430,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
async function onJoin(conn, msg) {
async function onJoin(conn, msg) {
const robotId = String(msg.robot_id || '');
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);
const robot = await domain.robots.get(robotId);
if (!robot) return sendError(conn, 'bot.robot_not_found', 'no such robot');
if (!robot) return sendError(conn, 'bot.robot_not_found', 'no such robot');
let role = await domain.members.roleOf(robotId, conn.subject);
let role = await domain.members.roleOf(robotId, conn.subject);
@@ -426,10 +441,13 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -426,10 +441,13 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
subscribe(conn);
subscribe(conn);
const profileRow = await getProfile(domain.db, robot.profile_id, robot.profile_version);
const profileRow = await getProfile(domain.db, robot.profile_id, robot.profile_version);
const profile = profileRow ? profileRow.profile : null;
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', {
sendFrame(conn, 'joined', {
robot: domain.present.robot(robot), role, profile,
robot: domain.present.robot(robot), role, profile,
allowed_commands: domain.control.allowedFor(robot, role, profile),
allowed_commands: domain.control.allowedFor(robot, role, profile),
state: await robotState(robotId, conn.subject),
state: await robotState(robotId, conn.subject),
+ ...(next ? { ticket: next.ticket } : {}),
});
});
}
}
@@ -728,6 +746,8 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -728,6 +746,8 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
bindDomain(d) { domain = d; },
bindDomain(d) { domain = d; },
bindJobs(j) { jobs = j; },
bindJobs(j) { jobs = j; },
handleUpgrade, attachSim, detachSim,
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,
isOnline, deviceState, sendToDevice, sendToRobotDevices, broadcast, closeDevice, robotState, refreshConfig, pushEstop, closeWatchers, dropSubject, operatorCommand,
// 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; },
@@ -351,7 +351,9 @@ function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs
@@ -351,7 +351,9 @@ function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs
const widgets = (profile.widgets || []).map((w, i) => ({ w, i }))
const widgets = (profile.widgets || []).map((w, i) => ({ w, i }))
.sort((a, b) => (a.w.order != null ? a.w.order : 1e9 + a.i) - (b.w.order != null ? b.w.order : 1e9 + b.i))
.sort((a, b) => (a.w.order != null ? a.w.order : 1e9 + a.i) - (b.w.order != null ? b.w.order : 1e9 + b.i))
.map(({ w }) => renderWidget(w, { profile, allowed_commands, signaling_url }));
.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 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}">
${embed ? '' : topbar(`<span class="crumb">${esc(robot.name)}</span>`)}
${embed ? '' : topbar(`<span class="crumb">${esc(robot.name)}</span>`)}
@@ -363,7 +365,7 @@ ${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>
<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)}
${turnStrip(robot, role)}
</div>
</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>
<p class="notice" data-notice role="alert" hidden></p>
<div class="widgets">
<div class="widgets">
${widgets.join('\n')}
${widgets.join('\n')}
@@ -373,6 +375,19 @@ ${role === 'owner' && !embed ? `<div class="owner-settings">${localProfileForm(r
@@ -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' });
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). */
/** The framed 403: no robot data, only a way out to openvibe.bot (where signing in may grant access). */
function renderEmbedRefused({ robotId }) {
function renderEmbedRefused({ robotId }) {
const body = `<main class="page embed-refused">
const body = `<main class="page embed-refused">
@@ -441,4 +456,4 @@ function renderPairingPage({ robot, pairing, profile = null }) {
@@ -441,4 +456,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 = { page, renderPanel, renderEmbedRefused, renderWidget, renderRobotsPage, renderPairingPage, camerasOf, esc };
+module.exports = { page, renderPanel, renderEmbedRefused, renderDrivePage, renderWidget, renderRobotsPage, renderPairingPage, camerasOf, esc };
@@ -36,7 +36,7 @@ const { getProfile, listProfiles } = require('../profiles');
@@ -36,7 +36,7 @@ const { getProfile, listProfiles } = require('../profiles');
const { frameAncestors } = require('../config');
const { frameAncestors } = require('../config');
const { BotError } = require('../util');
const { BotError } = require('../util');
const kits = require('../kits');
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 { renderDocsIndex, renderDriversPage, renderProfilesPage } = require('./docs');
const { checkProfile } = require('../profiles');
const { checkProfile } = require('../profiles');
const { renderHome, HOME_CSP } = require('./home');
const { renderHome, HOME_CSP } = require('./home');
@@ -84,6 +84,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
@@ -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)); };
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.js', asset('panel.js', 'application/javascript'));
r.get('/panel/panel.css', asset('panel.css', 'text/css'));
r.get('/panel/panel.css', asset('panel.css', 'text/css'));
+ r.get('/panel/drive.js', asset('drive.js', 'application/javascript'));
const requireUser = (req) => {
const requireUser = (req) => {
if (!req.viewer) throw new BotError(401, 'bot.sign_in', 'sign in first');
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
@@ -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 });
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.
// The embed's own CSP replaces the app's for this route only; it sets no cookie and needs none to watch.
r.get('/panel/:id/embed', page(async (req, res) => {
r.get('/panel/:id/embed', page(async (req, res) => {
res.setHeader('Content-Security-Policy', embedCsp(config));
res.setHeader('Content-Security-Policy', embedCsp(config));
@@ -54,6 +54,30 @@ const { boot, check, done } = require('./helpers/app');
@@ -54,6 +54,30 @@ const { boot, check, done } = require('./helpers/app');
op.close(); dev.close();
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 () => {
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 { dev, deviceId } = await connected();
const { robot: other } = await t.robot(alex);
const { robot: other } = await t.robot(alex);
new file mode 100644
new file mode 100644
@@ -0,0 +1,124 @@
@@ -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);
+})();
@@ -24,10 +24,10 @@ const { renderPanel } = require('../server/web/render');
@@ -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-estop>') && !watcher.includes('data-estop-clear') && !watcher.includes('data-embed-form'));
assert.ok(watcher.includes('data-role="watcher"') && watcher.includes('data-allowed="[]"'));
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.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);
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 });
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' });
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 '));
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'] });
const page = renderPanel({ robot: robotRow, profile: SIM, role: 'owner', allowed_commands: ['drive'] });
@@ -36,9 +36,9 @@ const { renderPanel } = require('../server/web/render');
@@ -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', () => {
await check('the panel client: a watcher joins on /watch and never sends anything but join; a camera tile plays only its URL', () => {
const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'panel.js'), 'utf8');
const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'panel.js'), 'utf8');
- assert.match(js, /const WATCHER = main\.dataset\.role === 'watcher';/);
- assert.match(js, /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 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.
// the sfu-viewer-* flow; a page with no tile carrying it never imports anything.
assert.match(js, /for \(const tile of \$\$\('\[data-signaling-url\]'\)\) liveCamera\(tile\);/);
assert.match(js, /for \(const tile of \$\$\('\[data-signaling-url\]'\)\) liveCamera\(tile\);/);
@@ -70,7 +70,7 @@ const { renderPanel } = require('../server/web/render');
@@ -70,7 +70,7 @@ const { renderPanel } = require('../server/web/render');
assert.strictEqual(r.headers.get('set-cookie'), null);
assert.strictEqual(r.headers.get('set-cookie'), null);
const html = await r.text();
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('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');
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.
// 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(`/panel/${open.id}`, alex)).headers.get('content-security-policy'), /frame-ancestors 'self';/);
@@ -90,9 +90,9 @@ const { renderPanel } = require('../server/web/render');
@@ -90,9 +90,9 @@ const { renderPanel } = require('../server/web/render');
const owner = await get(`/panel/${closed.id}/embed`, alex);
const owner = await get(`/panel/${closed.id}/embed`, alex);
assert.strictEqual(owner.status, 200);
assert.strictEqual(owner.status, 200);
const oh = await owner.text();
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();
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 () => {
await check('a live session\'s signaling URL reaches the anonymous public embed; a private robot is refused before it', async () => {
@@ -85,9 +85,10 @@ async function boot(opts = {}) {
@@ -85,9 +85,10 @@ async function boot(opts = {}) {
const SOCKET_WAIT_MS = 20000;
const SOCKET_WAIT_MS = 20000;
/** A WebSocket client to /device or /control. waitFor(pred) resolves the first matching frame. */
/** 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) => {
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 client = { ws: socket, messages: [], ended: false, waiters: [], closeCode: null };
const timer = setTimeout(() => reject(new Error(`ws open timeout on ${p}`)), SOCKET_WAIT_MS);
const timer = setTimeout(() => reject(new Error(`ws open timeout on ${p}`)), SOCKET_WAIT_MS);
socket.on('message', (raw) => {
socket.on('message', (raw) => {