openvibers/openvibe.bot Commits
Buttons under a Live stream in one press: an "On your stream" card (shows the panel where embedded, opens the Live bind prompt, who may drive), and an embedded panel's Drive gets its ticket from the Live page around it (#169)
Changes
@@@ -165,7 +165,7 @@ Request telemetry: each HTTP request is aggregated by route template, method andSet `BOT_PAIRING_AUTHORITY` to `bot` (the default) or `network`; any other value refuses to boot.--Set `BOT_EMBED_ORIGINS` (comma or space separated; default `https://openvibe.live,https://www.openvibe.live`) to the pages that may frame a robot's read-only panel (the CSP `frame-ancestors` list). Each entry is a bare https origin, with no path, query or wildcard (`http://localhost:<port>` only outside production); an invalid entry refuses to boot. A robot is embeddable only after its owner turns on `embed_public` with the form on its panel (`POST /robots/:id/embed`); the flag is web-only and never appears in `/api/v1`. Migration `0005_embed_public.sql` adds the column, off for every robot.++Set `BOT_EMBED_ORIGINS` (comma or space separated; default `https://openvibe.live,https://www.openvibe.live`) to the pages that may frame a robot's read-only panel (the CSP `frame-ancestors` list). Each entry is a bare https origin, with no path, query or wildcard (`http://localhost:<port>` only outside production); an invalid entry refuses to boot. A robot is embeddable only after its owner turns on `embed_public`: the **On your stream** card that leads the owner's panel does it in one press and opens their Live channel's bind prompt (`POST /robots/:id/on-stream`), and the switch is also in the settings (`POST /robots/:id/embed`); the flag is web-only and never appears in `/api/v1`. Framed by Live, the panel's Drive asks the page around it for a ticket (`POST /api/v1/robots/:id/control-ticket`, docs/protocol.md §2) so a viewer signed in on Live drives without a popup or a second sign-in. Migration `0005_embed_public.sql` adds the column, off for every robot.The embeddable panel is `GET /panel/:id/embed` (framed only by `BOT_EMBED_ORIGINS`; no cookie needed to watch) and its read-only socket is `wss://openvibe.bot/watch` (docs/protocol.md §2.1); nginx must upgrade `/watch` like `/control`. Framing takes both sides: the embedding page must also list Bot's origin — its `BASE_URL`, `https://openvibe.bot` in production — in its own CSP `frame-src`, computed from that page's own configuration (OpenVibe.Live's `LIVE_BOT_URL`, default `https://openvibe.bot`), or the browser refuses the frame even though `BOT_EMBED_ORIGINS` allows the framer. Set `BOT_WATCH_MAX_PER_IP` (default 20) and `BOT_WATCH_MAX_PER_ROBOT` (default 500) to cap open `/watch` sockets per client address and watchers per robot; over a cap the socket closes 4003.@@@ -356,7 +356,19 @@ and never billed twice.`ov_token` cookie (a Network user JWT, set by `/auth`) **or** `Authorization: Bearer <user JWT>`, or a**service token** with capability `bot.robot.control` (audience `openvibe.bot`) plus--`X-OV-Subject: usr_…` naming the person it acts for. Without one the socket closes with code **4002**.++`X-OV-Subject: usr_…` naming the person it acts for, **or** a Drive ticket in the subprotocols++(`Sec-WebSocket-Protocol: ov.ticket, ov.ticket.<ticket>`; the server answers `ov.ticket`). Without one the++socket closes with code **4002**.++A Drive ticket is one-time, lasts 120 s and is for one robot and one person: their role, as the gate decides it++when they join (another robot is `error` `bot.forbidden`). `joined` carries the next ticket for a reconnect;++a spent or expired one closes **4002**. A panel on another site gets one in two ways:++- `POST /panel/:id/ticket` from the panel's own Drive popup (`/panel/:id/drive`, signed in on openvibe.bot,++ same-origin only), handed to the panel by `postMessage` on Bot's own origin;++- `POST /api/v1/robots/:id/control-ticket` from the page that frames the panel (§3): the panel asks it with++ `postMessage({ type: "ov-bot-ticket-request", robot_id })` sent to each `BOT_EMBED_ORIGINS` origin, and++ takes an answer only from `window.parent` on one of them: `{ type: "ov-bot-ticket", robot_id, ticket }`, or++ `{ type: "ov-bot-ticket-none", robot_id, message }` (shown as is: sign in, or the owner decides who drives).++ With no answer in 2.5 s the next Drive opens the popup.The gate then applies the **acted-for subject's** role — a service acting for a viewer is a viewer.The same gate is reachable over HTTP as `POST /robots/:id/commands` (§3), so a bound channel's chat canforward commands without holding a socket. The HTTP gate is the same gate but not the same join: a caller@@@ -451,6 +463,13 @@ cookie. A signed-in visitor (the `ov_token` cookie) who is a member gets their rdecides (a stranger on a `queue` robot: `queue`) and drives over `/control`; anyone else gets the role`watcher` when the owner turned on `embed_public` (`POST /robots/:id/embed`), with every control disabledand a "Sign in to control" link that opens `/panel/:id` in a new tab; otherwise `403` with only a link out.++Framed, that `403` page also tells the page around it,++`postMessage({ type: "ov-bot-embed-refused", robot_id })` to each `BOT_EMBED_ORIGINS` origin++(`/panel/refused.js`), so the page can hide the box and show its owner where to turn embedding on. The++owner's panel leads with **On your stream**: one button (`POST /robots/:id/on-stream`, owner only,++same-origin) turns `embed_public` on and sends them to their Live channel's bind prompt++(`<LIVE_URL>/@<username>?bind_robot=<id>`), and a **Who may drive** select (`POST /robots/:id/access`,++`private`/`invite`/`queue`) sits beside it.An unknown robot is `404`. The e-stop state is always shown; the E-stop button only to an owner or operator.Its response carries `Content-Security-Policy: default-src 'self'; frame-ancestors 'self' <BOT_EMBED_ORIGINS>;object-src 'none'; base-uri 'self'` and `Cache-Control: no-store`; every other page keeps@@@ -521,6 +540,7 @@ node token, and a service token is judged on `bot.job.dispatch` alone.| `POST /robots/:id/estop` | owner/operator, `bot.robot.control` | `{ robot }` || `POST /robots/:id/estop/clear` | **owner only**, `bot.robot.control` | `{ robot }` || `POST /robots/:id/commands` | owner/operator, `bot.robot.control` | `{ robot_id, id, result, code?, reason?, latency_ms?, cached? }` — one command through the same gate, audit and per-subject `id` idempotency key as `/control` (a bound channel's chat forwards here instead of a hardware socket). `result` is `ack`, `nack` or `expired` (a repeated `id` answers `cached: true`); a gate refusal is its code as an RFC 9457 problem: `403 bot.not_an_operator` / `bot.read_only` / `bot.not_your_turn`, `404 bot.robot_not_found`, `409 bot.device_offline` / `bot.estop_latched` / `bot.command_pending` (the same `id` is still in flight), `422` the command-shape codes, `429 bot.cooldown` / `bot.turn_budget`. A service acting for a subject is rate-limited as that person (`user:<subject>`) and also, as a service, at 1200 a minute and 12000 an hour on its own token (`bot.control.service`), so naming a fresh well-formed subject on every request never lifts every ceiling |++| `POST /robots/:id/control-ticket` | `bot.robot.control` acting for a person (`X-OV-Subject`) who is a member, or anyone on a `queue` robot | `{ ticket, robot_id, role, expires_in }`, `Cache-Control: no-store`: a Drive ticket for that person (§2), for the page that frames the panel. `403 bot.not_an_operator`; `422` without a subject || `GET /robots/:id/steps` | the owner, or `svc:actor` with `bot.robot.read` acting for the owner of a robot with `agent_control` on | `{ robot_id, name, profile_id, online, estop, battery, sensors, steps[], max_steps, max_seconds }`: the robot's state and one example line per step word it takes (server/routines.js `stepHelp`) || `POST /robots/:id/steps` | the owner, or `svc:actor` with `bot.robot.control` acting for the owner of a robot with `agent_control` on | body `{ steps: "forward 1\nsay Hi" }` (routine step lines, at most 20 steps and 30 seconds, waits included) → `{ robot_id, result, steps }` once they end; `result` is `done` or `stopped: <why>` (the e-stop, the device going offline, a cooldown). Each step passes the gate as the owner and is audited as the caller. `403 bot.agent_not_allowed` while the owner's switch is off or for anyone but the owner, `403 bot.forbidden` for any other service, `409 bot.routine_busy` while a routine runs, `422 bot.invalid_routine` naming the wrong line. `POST /robots/:id/commands` holds `svc:actor` to the same switch and owner || `GET /robots/:id/audit?limit=&before=` | owner, or `bot.robot.read` | `{ audit[], next_before }` (newest first) |@@@ -251,6 +251,12 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf.form-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; }.setting-note { color: var(--muted); font-size: 0.88rem; margin: -0.35rem 0 0.7rem; }.setting-note a { color: var(--accent); }++/* "On your stream": the first owner card, so a streamer finds the one button that puts the panel under their stream. */++.setting-card.on-stream { border-color: color-mix(in srgb, var(--accent) 45%, var(--line)); }++.setting-card.on-stream form + .setting-note { margin-top: -0.2rem; }++.setting-card.on-stream .access-form { margin-top: 0.4rem; padding-top: 0.8rem; border-top: 1px solid var(--line); }++.setting-ok { display: flex; gap: 0.45rem; align-items: center; margin: 0 0 0.7rem; padding: 0.5rem 0.7rem; border-radius: 10px; background: color-mix(in srgb, var(--ok) 14%, transparent); font-size: 0.9rem; }++.setting-ok .icon { flex: none; width: 1.1rem; height: 1.1rem; color: var(--ok); }.toggle { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 0.75rem; align-items: start; min-height: var(--tap);padding: 0.65rem 0.75rem; border: 1px solid var(--line); border-radius: 10px; background: var(--raised); cursor: pointer; }.toggle:has(input:checked) { border-color: var(--accent); }@@@ -912,18 +912,43 @@if (bar) bar.hidden = false;if (text) notice(text);}++ // Framed by an allowed page (a Live channel the robot is bound to, data-parent-origins), Drive first asks that page:++ // the person is signed in there, so it gets the ticket for them with no popup or second sign-in. It answers with++ // the ticket, or with why not (`ov-bot-ticket-none`: sign in, or the owner decides who drives). No answer in time++ // and the next Drive opens the popup as before.++ const PARENTS = String(main.dataset.parentOrigins || '').split(' ').filter(Boolean);++ const framed = window.parent !== window && PARENTS.length > 0;++ let parentSilent = false;++ let asked = null;let popup = null;++ const openPopup = () => {++ 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');++ };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');++ if (!framed || parentSilent) return openPopup();++ for (const origin of PARENTS) {++ try { window.parent.postMessage({ type: 'ov-bot-ticket-request', robot_id: ROBOT_ID }, origin); } catch { /* not that origin */ }++ }++ notice('Asking for the controls…');++ clearTimeout(asked);++ asked = setTimeout(() => { parentSilent = true; notice('Press Drive again to sign in on openvibe.bot.'); }, 2500);});}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;++ // The popup this panel opened (this origin), or the allowed page around the frame; for this robot only.++ const fromPopup = e.origin === location.origin && popup && e.source === popup;++ const fromParent = framed && e.source === window.parent && PARENTS.includes(e.origin);++ if (!fromPopup && !fromParent) return;const d = e.data || {};++ if (fromParent && d.type === 'ov-bot-ticket-none' && d.robot_id === ROBOT_ID) {++ clearTimeout(asked);++ notice(typeof d.message === 'string' ? d.message.slice(0, 200) : 'You cannot drive this robot from here.');++ return;++ }if (d.type !== 'ov-bot-ticket' || d.robot_id !== ROBOT_ID || typeof d.ticket !== 'string') return;++ clearTimeout(asked);ticket = d.ticket; driving = true; watcher = false; gone = false;const bar = $('[data-drive-bar]');if (bar) bar.hidden = true;nnew file mode 100644@@@ -0,0 +1,11 @@++/* OpenVibe.Bot — the framed "not shown here yet" page (server/web/render.js renderEmbedRefused): it tells the page around++ the frame, only when that page is one of the allowed embedders, so a Live channel can hide the box from its viewers and++ point its owner to "Put the buttons under my OpenVibe.Live stream". It says nothing but the robot id the page framed. */++(() => {++ const el = document.querySelector('[data-embed-refused]');++ if (!el || window.parent === window) return;++ const robotId = el.getAttribute('data-embed-refused');++ for (const origin of String(el.getAttribute('data-parent-origins') || '').split(' ').filter(Boolean)) {++ try { window.parent.postMessage({ type: 'ov-bot-embed-refused', robot_id: robotId }, origin); } catch { /* not that origin */ }++ }++})();@@@ -26,6 +26,7 @@* | POST /robots/:id/estop | bot.robot.control | owner or operator |* | POST /robots/:id/estop/clear | bot.robot.control | the owner only |* | POST /robots/:id/commands | bot.robot.control | owner or operator |++ * | POST /robots/:id/control-ticket | bot.robot.control | a member (or anyone on a queue robot): a Drive ticket |* | GET /robots/:id/audit | bot.robot.read | owner (paged) |* | GET /robots/:id/streaming | bot.robot.read | a member |* | POST /robots/:id/streaming | bot.robot.manage | the owner |@@@ -360,6 +361,24 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, submres.json({ robot: domain.present.robot(await domain.robots.get(req.params.id)) });}));++ // ── A Drive ticket for an embedded panel (server/control-tickets.js) ───────────────────────────++ // The page that frames a robot's panel (a Live channel bound to it) asks for a person who is signed in there: the++ // frame cannot see an openvibe.bot session, and the popup Drive took (its own sign-in) was a step too many. The++ // ticket names this person and robot, lasts two minutes and is used once; the join that redeems it still decides++ // what they may do by their role, exactly as the popup's ticket.++ r.post('/robots/:id/control-ticket', commandControl, wrap(async (req, res) => {++ requireControlCap(req);++ const subject = actedFor(req);++ const robot = await domain.robots.get(req.params.id);++ if (!robot) fail(404, 'bot.robot_not_found', 'no such robot');++ const role = (await domain.members.roleOf(robot.id, subject)) || (robot.access_policy === 'queue' ? 'queue' : null);++ if (!role) fail(403, 'bot.not_an_operator', 'the owner decides who may drive this robot');++ const t = hub.issueControlTicket({ kind: 'user', subject }, robot.id);++ if (!t) fail(503, 'bot.busy', 'try again in a moment');++ res.setHeader('Cache-Control', 'no-store');++ res.json({ ticket: t.ticket, robot_id: robot.id, role, expires_in: t.expires_in });++ }));++// ── Commands (the /control gate over HTTP: a service forwards a bound channel's chat commands) ─// The same onOperatorCommand path as the /control socket — the gate, the audit and the per-subject// idempotency key are shared. `id` is that idempotency key; a repeated id is answered from the cache.@@@ -55,6 +55,7 @@ const PATHS = {robot: '<rect x="4" y="8" width="16" height="11" rx="2.5"/><path d="M12 4v4M9 13h.01M15 13h.01M2 13v2M22 13v2"/>',plug: '<path d="M9 2v5M15 2v5M6 7h12v4a6 6 0 0 1-12 0zM12 17v5"/>',gamepad: '<path d="M7 7h10a4 4 0 0 1 4 4l.8 5a2.6 2.6 0 0 1-4.6 2L15 15H9l-2.2 3a2.6 2.6 0 0 1-4.6-2L3 11a4 4 0 0 1 4-4z"/><path d="M7.5 10v3M6 11.5h3M16 11h.01M18 13h.01"/>',++ check: '<path d="M20 6 9 17l-5-5"/>',};const icon = (name, cls = 'icon') => `<svg class="${cls}" viewBox="0 0 24 24" aria-hidden="true" focusable="false">${PATHS[name] || ''}</svg>`;/** Hold-button icons by axis: [negative, positive]. */@@@ -327,10 +328,31 @@ ${toggle('listed', robot.listed, 'List it in the robot directory', 'It gets a pu</form>${robot.listed ? `<p class="setting-note"><a href="/r/${esc(robot.id)}">Its public page</a></p>` : ''}</section>`;}++/**++ * "On your stream" (owner, 2026-10-26, from a streamer's notes: "impossible to put buttons on my stream"): one button++ * puts this robot's panel under the owner's OpenVibe.Live stream (it shows the panel where it is embedded, then opens++ * their channel, which asks to bind it), and who may drive from there is chosen beside it. Plain forms, no JavaScript.++ */++function onStreamCard(robot, { liveUrl = null } = {}) {++ const policy = robot.access_policy || 'private';++ const state = robot.embed_public++ ? `<p class="setting-ok" role="status">${icon('check')}<span>Its panel shows wherever it is embedded. Bound to your channel, viewers see it under your stream.</span></p>`++ : '';++ return `<section class="setting-card on-stream" id="on-stream" aria-labelledby="on-stream-h"><h2 id="on-stream-h">On your stream</h2>++<p class="setting-note">Put this robot's buttons under your OpenVibe.Live stream so your viewers can drive it there. Your stream stays the video; the panel adds the buttons, the e-stop and the latency.</p>++${state}${liveUrl ? `<form method="post" action="/robots/${esc(robot.id)}/on-stream"><button type="submit" class="button primary">${icon('gamepad')}<span>Put the buttons under my OpenVibe.Live stream</span></button></form>++<p class="setting-note">Opens your channel on OpenVibe.Live, which asks you to confirm. To take it off, choose no robot there, or turn off the panel below.</p>` : ''}++<form class="access-form" method="post" action="/robots/${esc(robot.id)}/access">++<label class="field"><span class="field-label">Who may drive</span><select name="access_policy">${POLICIES.map(([v, label]) => `<option value="${esc(v)}"${v === policy ? ' selected' : ''}>${esc(label)}</option>`).join('')}</select></label>++<p class="setting-note">"Anyone, taking turns" lets every signed-in viewer queue for a turn. With the other two, only you and the people you add can drive; everyone else watches.</p>++<button type="submit">Save</button>++</form></section>`;++}++/** The owner's embed switch: plain markup, works without JavaScript. On the panel it is a card; in the robots list, a compact row. */function embedForm(robot, { card = true } = {}) {const form = `<form class="embed-form${card ? '' : ' embed-inline'}" method="post" action="/robots/${esc(robot.id)}/embed" data-embed-form>--${toggle('embed_public', robot.embed_public, 'Let anyone watch where it is embedded', "A page that embeds this robot shows its video and readouts to everyone; never the controls.")}++${toggle('embed_public', robot.embed_public, 'Show the panel where it is embedded (your OpenVibe.Live stream)', "Your stream's page and any other page that embeds this robot show its panel to everyone. Driving still needs who you allow below, through Drive.")}<button type="submit">Save</button></form>`;return card ? `<section class="setting-card" aria-labelledby="embed-h"><h2 id="embed-h">Embedding</h2>\n${form}</section>` : form;@@@ -358,7 +380,7 @@ function streamingForm(robot, streaming) {<p class="setting-note">Off until you turn them on. Operators and viewers cannot change these.</p>${why}<form class="streaming-form" method="post" action="/robots/${esc(robot.id)}/streaming" data-streaming-form>${toggle('media', s.media && s.media.on, 'Record to OpenVibe.Media', 'Each time the robot goes live, OpenRestream records the session to OpenVibe.Media as an unlisted video.', off, true)}--${toggle('live', s.live && s.live.on, 'Show on my OpenVibe.Live channel', 'Lets OpenRestream mirror the camera to your Live channel. It appears there once your channel plays OpenRestream streams.', off, true)}++${toggle('live', s.live && s.live.on, "Send the robot's camera to my OpenVibe.Live channel", "Video only: OpenRestream mirrors the camera to your Live channel. For the buttons under your stream, use \"On your stream\" above.", off, true)}<button type="submit"${off ? ' disabled' : ''}>Save</button></form></section>`;}@@@ -441,7 +463,7 @@ function turnStrip(robot, role) {return `<div class="turn" data-turn hidden><span class="turn-state" data-turn-state></span><span class="turn-meta" data-turn-meta></span>${leave}${give}</div>`;}--function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs = 150, mode = 'page', signedIn = true, streaming = null, people = null, signaling_url = null, profileForm = {}, tryIt = false, automation = null }) {++function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs = 150, mode = 'page', signedIn = true, streaming = null, people = null, signaling_url = null, profileForm = {}, tryIt = false, automation = null, parentOrigins = [], liveUrl = null }) {const embed = mode === 'embed';const latched = !!(robot.estop && robot.estop.latched);const widgets = (profile.widgets || []).map((w, i) => ({ w, i }))@@@ -455,7 +477,7 @@ function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs${embed ? '' : tryIt ? topbar('<span class="crumb">Try the panel</span>', '/') : topbar(`<span class="crumb">${esc(robot.name)}</span>`)}<div class="estop-bar"><p class="estop-state" role="status" aria-live="assertive"><span class="estop-dot"></span><span data-estop-state>${latched ? 'E-stop latched' : 'E-stop clear'}</span><span class="estop-note">${latched ? ' — the robot stays still until the owner clears it.' : ''}</span></p>${clear}${estop}</div></header>--<main id="panel" tabindex="0" data-robot-id="${esc(robot.id)}" data-role="${esc(role)}" data-hold-ms="${esc(holdResendMs)}" data-allowed="${data(allowed_commands)}"${tryIt ? ' data-transport="local"' : ''}>++<main id="panel" tabindex="0" data-robot-id="${esc(robot.id)}" data-role="${esc(role)}" data-hold-ms="${esc(holdResendMs)}" data-allowed="${data(allowed_commands)}"${tryIt ? ' data-transport="local"' : ''}${embed && parentOrigins.length ? ` data-parent-origins="${esc(parentOrigins.join(' '))}"` : ''}><div class="panel-head"><h1>${esc(robot.name)}</h1><p class="meta"><span class="pill online" data-online-pill data-state="connecting"><span class="dot"></span><span data-online-label>Connecting…</span></span> <span class="pill">${esc(profile.name || robot.profile_id)}</span> <span class="pill role" data-role-label>${esc(ROLE_LABEL[role] || role)}</span></p>@@@ -468,7 +490,7 @@ ${embed && role === 'watcher' && !signedIn ? `<p class="embed-sign-in" data-driv<div class="widgets">${widgets.join('\n')}</div>--${tryIt ? tryOutro(profile) : ''}${role === 'owner' && !embed && !tryIt ? `<div class="owner-settings">${localProfileForm(robot, profile, profileForm)}${automation ? routinesCard(robot, profile, automation) + webhooksCard(robot, automation) : ''}${agentForm(robot)}${listingForm(robot)}${embedForm(robot)}${streamingForm(robot, streaming)}${people ? peopleCard(robot, people) : ''}</div>` : ''}++${tryIt ? tryOutro(profile) : ''}${role === 'owner' && !embed && !tryIt ? `<div class="owner-settings">${onStreamCard(robot, { liveUrl })}${localProfileForm(robot, profile, profileForm)}${automation ? routinesCard(robot, profile, automation) + webhooksCard(robot, automation) : ''}${agentForm(robot)}${listingForm(robot)}${embedForm(robot)}${streamingForm(robot, streaming)}${people ? peopleCard(robot, people) : ''}</div>` : ''}</main>`;return page(tryIt ? 'Try the panel with a simulated rover' : robot.name, body, {scripts: panelScripts(profile, tryIt), bodyClass: embed ? 'embed-page' : 'panel-page',@@@ -512,12 +534,14 @@ function renderDrivePage({ robot }) {}/** The framed 403: no robot data, only a way out to openvibe.bot (where signing in may grant access). */--function renderEmbedRefused({ robotId }) {-- const body = `<main class="page embed-refused">--<div class="card"><p>This robot is not shared for embedding.</p>--<p>${outLink(`/panel/${robotId}`, 'Open on openvibe.bot', 'button primary')}</p></div>++function renderEmbedRefused({ robotId, parentOrigins = [] }) {++ const body = `<main class="page embed-refused" data-embed-refused="${esc(robotId)}" data-parent-origins="${esc(parentOrigins.join(' '))}">++<div class="card"><p>This robot's panel is not shown here yet.</p>++<p class="setting-note">Its owner turns it on with "Put the buttons under my OpenVibe.Live stream" on the robot's page.</p>++<p>${outLink(`/panel/${robotId}#on-stream`, 'Open on openvibe.bot', 'button primary')}</p></div></main>`;-- return page('Not shared', body, { bodyClass: 'embed-page' });++ // refused.js tells the page around the frame, so a stream page can hide the box or point its owner to the switch.++ return page('Not shown here yet', body, { bodyClass: 'embed-page', scripts: ['/panel/refused.js'] });}/**@@@ -15,6 +15,8 @@* otherwise 403 with a link out. Never redirects to sign in; framed only by the* BOT_EMBED_ORIGINS allow-list (CSP frame-ancestors), every other page by itself* POST /robots/:id/embed the owner only: allow (embed_public=on) or stop (off) anonymous read-only embedding → 303 to its panel++ * POST /robots/:id/on-stream the owner only: show the panel where it is embedded, then 303 to their Live channel's bind prompt++ * POST /robots/:id/access the owner only: who may drive (private | invite | queue) → 303 to its panel* POST /robots/:id/operators the owner only: add a person by @username with a role → 303 to its panel* POST /robots/:id/operators/:subject/remove the owner only: remove a person (never the owner row) → 303* POST /robots/:id/queue/leave any signed-in person: leave the robot's turn queue → 303@@@ -199,6 +201,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limir.get('/panel/panel.css', asset('panel.css', 'text/css'));r.get('/panel/screen-share.js', asset('screen-share.js', 'application/javascript'));r.get('/panel/drive.js', asset('drive.js', 'application/javascript'));++ r.get('/panel/refused.js', asset('refused.js', 'application/javascript'));r.get('/panel/sim-world.js', asset('sim-world.js', 'application/javascript'));r.get('/panel/sim-view.js', asset('sim-view.js', 'application/javascript'));r.get('/panel/try-device.js', asset('try-device.js', 'application/javascript'));@@@ -811,6 +814,36 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limires.redirect(303, `/panel/${robot.id}`);}));++ // "Put the buttons under my OpenVibe.Live stream": show the panel where it is embedded, then open the owner's own Live++ // channel with ?bind_robot=, where Live asks them to confirm the binding (Live's bot-bind.js). The username is the++ // same on every site.++ r.post('/robots/:id/on-stream', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {++ const me = requireUser(req);++ if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');++ const robot = await domain.robots.get(req.params.id);++ if (!robot) throw new BotError(404, 'bot.robot_not_found', 'no such robot');++ if (robot.owner_subject !== me.subject) throw new BotError(403, 'bot.forbidden', 'only the owner may put it on a stream');++ if (!robot.embed_public) await domain.robots.setEmbedPublic(robot.id, true);++ const liveOrigin = (config.embed && config.embed.origins && config.embed.origins[0]) || null;++ const username = req.viewerClaims && typeof req.viewerClaims.username === 'string' ? req.viewerClaims.username : '';++ if (!liveOrigin || !username) return res.redirect(303, `/panel/${robot.id}#on-stream`);++ return res.redirect(303, `${liveOrigin}/@${encodeURIComponent(username)}?bind_robot=${encodeURIComponent(robot.id)}`);++ }));++++ // Who may drive (the robot's access policy), from the owner's page: it was only settable when a robot was made.++ r.post('/robots/:id/access', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {++ const me = requireUser(req);++ if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');++ const robot = await domain.robots.get(req.params.id);++ if (!robot) throw new BotError(404, 'bot.robot_not_found', 'no such robot');++ if (robot.owner_subject !== me.subject) throw new BotError(403, 'bot.forbidden', 'only the owner may choose who drives');++ const policy = String((req.body && req.body.access_policy) || '');++ if (!['private', 'invite', 'queue'].includes(policy)) throw new BotError(422, 'bot.invalid_policy', 'choose who may drive');++ await domain.robots.update(robot.id, { access_policy: policy });++ if (hub) hub.broadcast(robot.id);++ res.redirect(303, `/panel/${robot.id}#on-stream`);++ }));++r.post('/robots/:id/embed', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {const me = requireUser(req);if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');@@@ -927,9 +960,14 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limifor (const h of hooks) { const d = (await webhooks.recent(h.id))[0]; h.last = d ? `${d.state}${d.last_status ? ` (${d.last_status})` : ''}` : null; }automation = { routines: await routines.list(robot.id), hooks, events: webhooks.EVENTS, describe: (t) => routines.describeTrigger(typeof t === 'string' ? JSON.parse(t) : t), ...(profileForm.automation || {}) };}++ const liveOrigin = (config.embed && config.embed.origins && config.embed.origins[0]) || null;res.type('html').send(renderPanel({robot: presentForPage(robot), profile, role, mode, signedIn, streaming, people, signaling_url,profileForm, automation,++ // The page that frames the embed may hand it a Drive ticket (public/panel.js); the owner's page offers to put++ // the panel under their Live stream.++ parentOrigins: mode === 'embed' ? ((config.embed && config.embed.origins) || []) : [],++ liveUrl: role === 'owner' && mode === 'page' ? liveOrigin : null,allowed_commands: role === 'watcher' ? [] : domain.control.allowedFor(robot, role, profile),// A held control is re-sent well inside the device's deadline, so it never stops between two frames.holdResendMs: Math.max(50, Math.min(HOLD_RESEND_MS, Math.floor(maxCommandMs / 2))),@@@ -1057,7 +1095,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limiif (!robot) throw new BotError(404, 'bot.robot_not_found', 'no such robot');let role = req.viewer ? await memberRole(robot, req.viewer) : null;if (!role && robot.embed_public) role = 'watcher';-- if (!role) return res.status(403).type('html').send(renderEmbedRefused({ robotId: robot.id }));++ if (!role) return res.status(403).type('html').send(renderEmbedRefused({ robotId: robot.id, parentOrigins: (config.embed && config.embed.origins) || [] }));const profile = await profileOf(robot);if (!profile) throw new BotError(404, 'bot.profile_not_found', 'this robot has no profile');return sendPanel(res, robot, profile, role, 'embed', !!req.viewer);@@@ -38,7 +38,9 @@ const { renderPanel } = require('../server/web/render');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;/);++ // Only its own popup, or the allowed page around the frame (stream-buttons.test.js), hands it a ticket.++ assert.match(js, /const fromPopup = e\.origin === location\.origin && popup && e\.source === popup;/);++ assert.match(js, /if \(!fromPopup && !fromParent\) 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');@@@ -63,7 +63,7 @@ const { loadConfig, frameAncestors } = require('../server/config');const html = await (await get(`/panel/${robot.id}`, alex)).text();assert.match(html, new RegExp(`action="/robots/${robot.id}/embed"`));assert.match(html, /name="embed_public" value="on" checked/);-- assert.match(html, /never the controls/);++ assert.match(html, /Driving still needs who you allow/);const r = await t.call('GET', `/api/v1/robots/${robot.id}`, { user: alex });assert.ok(!r.text.includes('embed_public'));const off = await post(url, alex, { embed_public: 'off' });@@@ -84,7 +84,7 @@ const { renderPanel } = require('../server/web/render');assert.strictEqual(r.headers.get('set-cookie'), null);const html = await r.text();assert.ok(!html.includes('Private rover') && !html.includes('data-robot-id'));-- assert.match(html, /href="\/panel\/rob_[0-9A-Za-z]+" target="_blank" rel="noopener">Open on openvibe\.bot</);++ assert.match(html, /href="\/panel\/rob_[0-9A-Za-z]+#on-stream" target="_blank" rel="noopener">Open on openvibe\.bot</, 'to the owner\'s "On your stream" card');assert.strictEqual((await get(`/panel/${closed.id}/embed`, bob)).status, 403);assert.strictEqual((await get('/panel/rob_nothing/embed')).status, 404);const owner = await get(`/panel/${closed.id}/embed`, alex);nnew file mode 100644@@@ -0,0 +1,120 @@++'use strict';++// Buttons under a Live stream, from a streamer's notes (Maticus, 2026-10-25/26: "impossible to put buttons on my++// stream", "the embedding toggle is off by default", "binding is a separate step and nothing points you to it", "who may++// drive is API-only", "driving takes an extra popup and sign-in"):++// - The owner's page leads with "On your stream": one button shows the panel where it is embedded and opens their Live++// channel's bind prompt; who may drive is chosen beside it. Others cannot do either.++// - POST /api/v1/robots/:id/control-ticket: the page around the frame (Live, a service with bot.robot.control) gets a++// Drive ticket for a person signed in there: a member, or anyone on a queue robot; the ticket opens /control.++// - The embedded panel asks the allowed page around it for that ticket (and takes it only from there or its own popup);++// the framed refusal tells that page, so it can hide the box and point the owner to the switch.++// - The wording says what each switch does (the camera mirror is video only).++const fs = require('fs');++const path = require('path');++const assert = require('assert');++const { boot, check, done } = require('./helpers/app');++const { renderPanel, renderEmbedRefused } = require('../server/web/render');++++(async () => {++ const t = await boot();++ const alex = t.network.newUser('alex');++ const bob = t.network.newUser('bob');++ const cookie = (user) => ({ Cookie: `ov_token=${t.network.signUser(user)}` });++ const get = (p, user) => fetch(t.base + p, { redirect: 'manual', headers: user ? cookie(user) : {} });++ const post = (p, user, form, headers = {}) => fetch(t.base + p, {++ method: 'POST', redirect: 'manual', body: new URLSearchParams(form).toString(),++ headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...(user ? cookie(user) : {}), ...headers },++ });++ const row = async (id) => t.db.maybe('SELECT embed_public, access_policy FROM robots WHERE id = $1', [id]);++++ const { robot } = await t.robot(alex, { name: 'Cozmo Hobo' });++ const { robot: open } = await t.robot(alex, { name: 'Queue rover', access_policy: 'queue' });++++ await check('the owner\'s page leads with "On your stream": the button, who may drive, the plain wording', async () => {++ const page = await (await get(`/panel/${robot.id}`, alex)).text();++ const card = page.indexOf('id="on-stream"');++ assert.ok(card > 0 && card < page.indexOf('Buttons'), 'the first owner card');++ assert.ok(page.includes(`action="/robots/${robot.id}/on-stream"`) && page.includes('Put the buttons under my OpenVibe.Live stream'));++ assert.ok(page.includes(`action="/robots/${robot.id}/access"`) && page.includes('<option value="private" selected>Only me and people I add</option>'));++ assert.ok(page.includes('Show the panel where it is embedded (your OpenVibe.Live stream)'), 'the embedding switch says where');++ assert.ok(!page.includes('Let anyone watch where it is embedded'));++ const other = await (await get(`/panel/${open.id}`, bob)).text();++ assert.ok(!other.includes('id="on-stream"'), 'only the owner sees it');++ });++++ await check('"Put the buttons under my stream" shows the panel where embedded and opens the Live bind prompt', async () => {++ assert.strictEqual((await row(robot.id)).embed_public, false);++ const r = await post(`/robots/${robot.id}/on-stream`, alex, {});++ assert.strictEqual(r.status, 303);++ assert.strictEqual(r.headers.get('location'), `https://openvibe.live/@alex?bind_robot=${robot.id}`);++ assert.strictEqual((await row(robot.id)).embed_public, true);++ assert.strictEqual((await post(`/robots/${robot.id}/on-stream`, bob, {})).status, 403, 'not the owner');++ assert.strictEqual((await post(`/robots/${robot.id}/on-stream`, alex, {}, { Origin: 'https://evil.example' })).status, 403, 'cross-site');++ const page = await (await get(`/panel/${robot.id}`, alex)).text();++ assert.ok(page.includes('class="setting-ok"'), 'the card says it is on');++ });++++ await check('who may drive: the owner chooses it on the page', async () => {++ const r = await post(`/robots/${robot.id}/access`, alex, { access_policy: 'queue' });++ assert.strictEqual(r.status, 303);++ assert.strictEqual(r.headers.get('location'), `/panel/${robot.id}#on-stream`);++ assert.strictEqual((await row(robot.id)).access_policy, 'queue');++ assert.strictEqual((await post(`/robots/${robot.id}/access`, alex, { access_policy: 'everyone' })).status, 422);++ assert.strictEqual((await post(`/robots/${robot.id}/access`, bob, { access_policy: 'private' })).status, 403);++ await post(`/robots/${robot.id}/access`, alex, { access_policy: 'private' });++ assert.strictEqual((await row(robot.id)).access_policy, 'private');++ });++++ await check('POST /api/v1/robots/:id/control-ticket: for a member or a queue robot, as the person; a ticket that drives', async () => {++ const forPerson = (id, user, cap = ['bot.robot.control']) => t.call('POST', `/api/v1/robots/${id}/control-ticket`, { cap, headers: user ? { 'X-OV-Subject': user.subject } : {} });++ const mine = await forPerson(robot.id, alex);++ assert.strictEqual(mine.status, 200, mine.text);++ assert.match(mine.json.ticket, /^[A-Za-z0-9_-]{43}$/);++ assert.deepStrictEqual([mine.json.robot_id, mine.json.role], [robot.id, 'owner']);++ assert.strictEqual(mine.headers.get('cache-control'), 'no-store');++ const stranger = await forPerson(robot.id, bob);++ assert.deepStrictEqual([stranger.status, stranger.json.code], [403, 'bot.not_an_operator'], 'a private robot');++ const queue = await forPerson(open.id, bob);++ assert.deepStrictEqual([queue.status, queue.json.role], [200, 'queue'], 'anyone signed in on a queue robot');++ assert.strictEqual((await forPerson(robot.id, alex, ['bot.robot.read'])).status, 403, 'needs bot.robot.control');++ assert.strictEqual((await forPerson(robot.id, null)).status, 422, 'a service must name the person');++ assert.strictEqual((await t.call('POST', `/api/v1/robots/${robot.id}/control-ticket`, { user: bob })).status, 403, 'a person asking for their own, on a private robot');++ // The ticket opens /control as that person.++ const ws = await t.ws('/control', { protocols: ['ov.ticket', `ov.ticket.${mine.json.ticket}`] });++ ws.send({ type: 'join', robot_id: robot.id });++ const joined = await ws.waitFor((m) => m.type === 'joined' || m.type === 'error');++ assert.deepStrictEqual([joined.type, joined.role], ['joined', 'owner'], JSON.stringify(joined));++ ws.close();++ });++++ await check('the embedded panel asks its allowed page for a ticket; the refusal tells that page', () => {++ 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', mode: 'embed', signedIn: false, parentOrigins: ['https://openvibe.live', 'https://www.openvibe.live'] });++ assert.ok(html.includes('data-parent-origins="https://openvibe.live https://www.openvibe.live"'));++ const page = renderPanel({ robot: { id: 'rob_d1', name: 'Rover', profile_id: 'sim.rover', estop: { latched: false } }, profile: SIM, role: 'watcher', mode: 'page', parentOrigins: ['https://openvibe.live'] });++ assert.ok(!page.includes('data-parent-origins'), 'only a framed panel');++ const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'panel.js'), 'utf8');++ assert.ok(js.includes("window.parent.postMessage({ type: 'ov-bot-ticket-request', robot_id: ROBOT_ID }, origin)"), 'asks each allowed origin only');++ assert.ok(js.includes('const fromParent = framed && e.source === window.parent && PARENTS.includes(e.origin);'), 'takes it from that page only');++ assert.ok(js.includes('const fromPopup = e.origin === location.origin && popup && e.source === popup;'), 'or from its own popup');++ const refused = renderEmbedRefused({ robotId: 'rob_d1', parentOrigins: ['https://openvibe.live'] });++ assert.ok(refused.includes('src="/panel/refused.js"') && refused.includes('data-parent-origins="https://openvibe.live"'));++ assert.ok(refused.includes('/panel/rob_d1#on-stream') && refused.includes('Put the buttons under my OpenVibe.Live stream'));++ const rjs = fs.readFileSync(path.join(__dirname, '..', 'public', 'refused.js'), 'utf8');++ assert.ok(rjs.includes("window.parent.postMessage({ type: 'ov-bot-embed-refused', robot_id: robotId }, origin)"));++ });++++ await check('/panel/refused.js is served', async () => {++ const r = await get('/panel/refused.js');++ assert.strictEqual(r.status, 200);++ assert.match(r.headers.get('content-type'), /javascript/);++ });++++ await check('the camera mirror says it is video only', async () => {++ const page = await (await get(`/panel/${robot.id}`, alex)).text();++ assert.ok(page.includes("Send the robot's camera to my OpenVibe.Live channel") || page.includes("Send the robot's camera to my OpenVibe.Live channel"));++ });++++ await t.close();++ done();++})();@@@ -171,7 +171,7 @@ const { boot, check, done } = require('./helpers/app');let html = await (await page(`/panel/${robot.id}`, alex)).text();assert.match(html, new RegExp(`action="/robots/${robot.id}/streaming"`));assert.match(html, /Record to OpenVibe\.Media/);-- assert.match(html, /Show on my OpenVibe\.Live channel/);++ assert.match(html, /Send the robot('|')s camera to my OpenVibe\.Live channel/, 'video only; the buttons are "On your stream"');assert.doesNotMatch(html, /name="(media|live)" value="on" checked/, 'both off by default');const saved = await form(`/robots/${robot.id}/streaming`, alex, 'media_was=off&media=off&media=on&live_was=off&live=off');assert.strictEqual(saved.status, 303);
@@ -165,7 +165,7 @@ Request telemetry: each HTTP request is aggregated by route template, method and
@@ -165,7 +165,7 @@ Request telemetry: each HTTP request is aggregated by route template, method and
Set `BOT_PAIRING_AUTHORITY` to `bot` (the default) or `network`; any other value refuses to boot.
Set `BOT_PAIRING_AUTHORITY` to `bot` (the default) or `network`; any other value refuses to boot.
-Set `BOT_EMBED_ORIGINS` (comma or space separated; default `https://openvibe.live,https://www.openvibe.live`) to the pages that may frame a robot's read-only panel (the CSP `frame-ancestors` list). Each entry is a bare https origin, with no path, query or wildcard (`http://localhost:<port>` only outside production); an invalid entry refuses to boot. A robot is embeddable only after its owner turns on `embed_public` with the form on its panel (`POST /robots/:id/embed`); the flag is web-only and never appears in `/api/v1`. Migration `0005_embed_public.sql` adds the column, off for every robot.
+Set `BOT_EMBED_ORIGINS` (comma or space separated; default `https://openvibe.live,https://www.openvibe.live`) to the pages that may frame a robot's read-only panel (the CSP `frame-ancestors` list). Each entry is a bare https origin, with no path, query or wildcard (`http://localhost:<port>` only outside production); an invalid entry refuses to boot. A robot is embeddable only after its owner turns on `embed_public`: the **On your stream** card that leads the owner's panel does it in one press and opens their Live channel's bind prompt (`POST /robots/:id/on-stream`), and the switch is also in the settings (`POST /robots/:id/embed`); the flag is web-only and never appears in `/api/v1`. Framed by Live, the panel's Drive asks the page around it for a ticket (`POST /api/v1/robots/:id/control-ticket`, docs/protocol.md §2) so a viewer signed in on Live drives without a popup or a second sign-in. Migration `0005_embed_public.sql` adds the column, off for every robot.
The embeddable panel is `GET /panel/:id/embed` (framed only by `BOT_EMBED_ORIGINS`; no cookie needed to watch) and its read-only socket is `wss://openvibe.bot/watch` (docs/protocol.md §2.1); nginx must upgrade `/watch` like `/control`. Framing takes both sides: the embedding page must also list Bot's origin — its `BASE_URL`, `https://openvibe.bot` in production — in its own CSP `frame-src`, computed from that page's own configuration (OpenVibe.Live's `LIVE_BOT_URL`, default `https://openvibe.bot`), or the browser refuses the frame even though `BOT_EMBED_ORIGINS` allows the framer. Set `BOT_WATCH_MAX_PER_IP` (default 20) and `BOT_WATCH_MAX_PER_ROBOT` (default 500) to cap open `/watch` sockets per client address and watchers per robot; over a cap the socket closes 4003.
The embeddable panel is `GET /panel/:id/embed` (framed only by `BOT_EMBED_ORIGINS`; no cookie needed to watch) and its read-only socket is `wss://openvibe.bot/watch` (docs/protocol.md §2.1); nginx must upgrade `/watch` like `/control`. Framing takes both sides: the embedding page must also list Bot's origin — its `BASE_URL`, `https://openvibe.bot` in production — in its own CSP `frame-src`, computed from that page's own configuration (OpenVibe.Live's `LIVE_BOT_URL`, default `https://openvibe.bot`), or the browser refuses the frame even though `BOT_EMBED_ORIGINS` allows the framer. Set `BOT_WATCH_MAX_PER_IP` (default 20) and `BOT_WATCH_MAX_PER_ROBOT` (default 500) to cap open `/watch` sockets per client address and watchers per robot; over a cap the socket closes 4003.
@@ -356,7 +356,19 @@ and never billed twice.
@@ -356,7 +356,19 @@ and never billed twice.
`ov_token` cookie (a Network user JWT, set by `/auth`) **or** `Authorization: Bearer <user JWT>`, or a
`ov_token` cookie (a Network user JWT, set by `/auth`) **or** `Authorization: Bearer <user JWT>`, or a
**service token** with capability `bot.robot.control` (audience `openvibe.bot`) plus
**service token** with capability `bot.robot.control` (audience `openvibe.bot`) plus
-`X-OV-Subject: usr_…` naming the person it acts for. Without one the socket closes with code **4002**.
+`X-OV-Subject: usr_…` naming the person it acts for, **or** a Drive ticket in the subprotocols
+(`Sec-WebSocket-Protocol: ov.ticket, ov.ticket.<ticket>`; the server answers `ov.ticket`). Without one the
+socket closes with code **4002**.
+A Drive ticket is one-time, lasts 120 s and is for one robot and one person: their role, as the gate decides it
+when they join (another robot is `error` `bot.forbidden`). `joined` carries the next ticket for a reconnect;
+a spent or expired one closes **4002**. A panel on another site gets one in two ways:
+- `POST /panel/:id/ticket` from the panel's own Drive popup (`/panel/:id/drive`, signed in on openvibe.bot,
+ same-origin only), handed to the panel by `postMessage` on Bot's own origin;
+- `POST /api/v1/robots/:id/control-ticket` from the page that frames the panel (§3): the panel asks it with
+ `postMessage({ type: "ov-bot-ticket-request", robot_id })` sent to each `BOT_EMBED_ORIGINS` origin, and
+ takes an answer only from `window.parent` on one of them: `{ type: "ov-bot-ticket", robot_id, ticket }`, or
+ `{ type: "ov-bot-ticket-none", robot_id, message }` (shown as is: sign in, or the owner decides who drives).
+ With no answer in 2.5 s the next Drive opens the popup.
The gate then applies the **acted-for subject's** role — a service acting for a viewer is a viewer.
The gate then applies the **acted-for subject's** role — a service acting for a viewer is a viewer.
The same gate is reachable over HTTP as `POST /robots/:id/commands` (§3), so a bound channel's chat can
The same gate is reachable over HTTP as `POST /robots/:id/commands` (§3), so a bound channel's chat can
forward commands without holding a socket. The HTTP gate is the same gate but not the same join: a caller
forward commands without holding a socket. The HTTP gate is the same gate but not the same join: a caller
@@ -451,6 +463,13 @@ cookie. A signed-in visitor (the `ov_token` cookie) who is a member gets their r
@@ -451,6 +463,13 @@ cookie. A signed-in visitor (the `ov_token` cookie) who is a member gets their r
decides (a stranger on a `queue` robot: `queue`) and drives over `/control`; anyone else gets the role
decides (a stranger on a `queue` robot: `queue`) and drives over `/control`; anyone else gets the role
`watcher` when the owner turned on `embed_public` (`POST /robots/:id/embed`), with every control disabled
`watcher` when the owner turned on `embed_public` (`POST /robots/:id/embed`), with every control disabled
and a "Sign in to control" link that opens `/panel/:id` in a new tab; otherwise `403` with only a link out.
and a "Sign in to control" link that opens `/panel/:id` in a new tab; otherwise `403` with only a link out.
+Framed, that `403` page also tells the page around it,
+`postMessage({ type: "ov-bot-embed-refused", robot_id })` to each `BOT_EMBED_ORIGINS` origin
+(`/panel/refused.js`), so the page can hide the box and show its owner where to turn embedding on. The
+owner's panel leads with **On your stream**: one button (`POST /robots/:id/on-stream`, owner only,
+same-origin) turns `embed_public` on and sends them to their Live channel's bind prompt
+(`<LIVE_URL>/@<username>?bind_robot=<id>`), and a **Who may drive** select (`POST /robots/:id/access`,
+`private`/`invite`/`queue`) sits beside it.
An unknown robot is `404`. The e-stop state is always shown; the E-stop button only to an owner or operator.
An unknown robot is `404`. The e-stop state is always shown; the E-stop button only to an owner or operator.
Its response carries `Content-Security-Policy: default-src 'self'; frame-ancestors 'self' <BOT_EMBED_ORIGINS>;
Its response carries `Content-Security-Policy: default-src 'self'; frame-ancestors 'self' <BOT_EMBED_ORIGINS>;
object-src 'none'; base-uri 'self'` and `Cache-Control: no-store`; every other page keeps
object-src 'none'; base-uri 'self'` and `Cache-Control: no-store`; every other page keeps
@@ -521,6 +540,7 @@ node token, and a service token is judged on `bot.job.dispatch` alone.
@@ -521,6 +540,7 @@ node token, and a service token is judged on `bot.job.dispatch` alone.
| `POST /robots/:id/estop` | owner/operator, `bot.robot.control` | `{ robot }` |
| `POST /robots/:id/estop` | owner/operator, `bot.robot.control` | `{ robot }` |
| `POST /robots/:id/estop/clear` | **owner only**, `bot.robot.control` | `{ robot }` |
| `POST /robots/:id/estop/clear` | **owner only**, `bot.robot.control` | `{ robot }` |
| `POST /robots/:id/commands` | owner/operator, `bot.robot.control` | `{ robot_id, id, result, code?, reason?, latency_ms?, cached? }` — one command through the same gate, audit and per-subject `id` idempotency key as `/control` (a bound channel's chat forwards here instead of a hardware socket). `result` is `ack`, `nack` or `expired` (a repeated `id` answers `cached: true`); a gate refusal is its code as an RFC 9457 problem: `403 bot.not_an_operator` / `bot.read_only` / `bot.not_your_turn`, `404 bot.robot_not_found`, `409 bot.device_offline` / `bot.estop_latched` / `bot.command_pending` (the same `id` is still in flight), `422` the command-shape codes, `429 bot.cooldown` / `bot.turn_budget`. A service acting for a subject is rate-limited as that person (`user:<subject>`) and also, as a service, at 1200 a minute and 12000 an hour on its own token (`bot.control.service`), so naming a fresh well-formed subject on every request never lifts every ceiling |
| `POST /robots/:id/commands` | owner/operator, `bot.robot.control` | `{ robot_id, id, result, code?, reason?, latency_ms?, cached? }` — one command through the same gate, audit and per-subject `id` idempotency key as `/control` (a bound channel's chat forwards here instead of a hardware socket). `result` is `ack`, `nack` or `expired` (a repeated `id` answers `cached: true`); a gate refusal is its code as an RFC 9457 problem: `403 bot.not_an_operator` / `bot.read_only` / `bot.not_your_turn`, `404 bot.robot_not_found`, `409 bot.device_offline` / `bot.estop_latched` / `bot.command_pending` (the same `id` is still in flight), `422` the command-shape codes, `429 bot.cooldown` / `bot.turn_budget`. A service acting for a subject is rate-limited as that person (`user:<subject>`) and also, as a service, at 1200 a minute and 12000 an hour on its own token (`bot.control.service`), so naming a fresh well-formed subject on every request never lifts every ceiling |
+| `POST /robots/:id/control-ticket` | `bot.robot.control` acting for a person (`X-OV-Subject`) who is a member, or anyone on a `queue` robot | `{ ticket, robot_id, role, expires_in }`, `Cache-Control: no-store`: a Drive ticket for that person (§2), for the page that frames the panel. `403 bot.not_an_operator`; `422` without a subject |
| `GET /robots/:id/steps` | the owner, or `svc:actor` with `bot.robot.read` acting for the owner of a robot with `agent_control` on | `{ robot_id, name, profile_id, online, estop, battery, sensors, steps[], max_steps, max_seconds }`: the robot's state and one example line per step word it takes (server/routines.js `stepHelp`) |
| `GET /robots/:id/steps` | the owner, or `svc:actor` with `bot.robot.read` acting for the owner of a robot with `agent_control` on | `{ robot_id, name, profile_id, online, estop, battery, sensors, steps[], max_steps, max_seconds }`: the robot's state and one example line per step word it takes (server/routines.js `stepHelp`) |
| `POST /robots/:id/steps` | the owner, or `svc:actor` with `bot.robot.control` acting for the owner of a robot with `agent_control` on | body `{ steps: "forward 1\nsay Hi" }` (routine step lines, at most 20 steps and 30 seconds, waits included) → `{ robot_id, result, steps }` once they end; `result` is `done` or `stopped: <why>` (the e-stop, the device going offline, a cooldown). Each step passes the gate as the owner and is audited as the caller. `403 bot.agent_not_allowed` while the owner's switch is off or for anyone but the owner, `403 bot.forbidden` for any other service, `409 bot.routine_busy` while a routine runs, `422 bot.invalid_routine` naming the wrong line. `POST /robots/:id/commands` holds `svc:actor` to the same switch and owner |
| `POST /robots/:id/steps` | the owner, or `svc:actor` with `bot.robot.control` acting for the owner of a robot with `agent_control` on | body `{ steps: "forward 1\nsay Hi" }` (routine step lines, at most 20 steps and 30 seconds, waits included) → `{ robot_id, result, steps }` once they end; `result` is `done` or `stopped: <why>` (the e-stop, the device going offline, a cooldown). Each step passes the gate as the owner and is audited as the caller. `403 bot.agent_not_allowed` while the owner's switch is off or for anyone but the owner, `403 bot.forbidden` for any other service, `409 bot.routine_busy` while a routine runs, `422 bot.invalid_routine` naming the wrong line. `POST /robots/:id/commands` holds `svc:actor` to the same switch and owner |
| `GET /robots/:id/audit?limit=&before=` | owner, or `bot.robot.read` | `{ audit[], next_before }` (newest first) |
| `GET /robots/:id/audit?limit=&before=` | owner, or `bot.robot.read` | `{ audit[], next_before }` (newest first) |
@@ -251,6 +251,12 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf
@@ -251,6 +251,12 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf
.form-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; }
.form-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; }
.setting-note { color: var(--muted); font-size: 0.88rem; margin: -0.35rem 0 0.7rem; }
.setting-note { color: var(--muted); font-size: 0.88rem; margin: -0.35rem 0 0.7rem; }
.setting-note a { color: var(--accent); }
.setting-note a { color: var(--accent); }
+/* "On your stream": the first owner card, so a streamer finds the one button that puts the panel under their stream. */
+.setting-card.on-stream { border-color: color-mix(in srgb, var(--accent) 45%, var(--line)); }
+.setting-card.on-stream form + .setting-note { margin-top: -0.2rem; }
+.setting-card.on-stream .access-form { margin-top: 0.4rem; padding-top: 0.8rem; border-top: 1px solid var(--line); }
+.setting-ok { display: flex; gap: 0.45rem; align-items: center; margin: 0 0 0.7rem; padding: 0.5rem 0.7rem; border-radius: 10px; background: color-mix(in srgb, var(--ok) 14%, transparent); font-size: 0.9rem; }
+.setting-ok .icon { flex: none; width: 1.1rem; height: 1.1rem; color: var(--ok); }
.toggle { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 0.75rem; align-items: start; min-height: var(--tap);
.toggle { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 0.75rem; align-items: start; min-height: var(--tap);
padding: 0.65rem 0.75rem; border: 1px solid var(--line); border-radius: 10px; background: var(--raised); cursor: pointer; }
padding: 0.65rem 0.75rem; border: 1px solid var(--line); border-radius: 10px; background: var(--raised); cursor: pointer; }
.toggle:has(input:checked) { border-color: var(--accent); }
.toggle:has(input:checked) { border-color: var(--accent); }
@@ -912,18 +912,43 @@
@@ -912,18 +912,43 @@
if (bar) bar.hidden = false;
if (bar) bar.hidden = false;
if (text) notice(text);
if (text) notice(text);
}
}
+ // Framed by an allowed page (a Live channel the robot is bound to, data-parent-origins), Drive first asks that page:
+ // the person is signed in there, so it gets the ticket for them with no popup or second sign-in. It answers with
+ // the ticket, or with why not (`ov-bot-ticket-none`: sign in, or the owner decides who drives). No answer in time
+ // and the next Drive opens the popup as before.
+ const PARENTS = String(main.dataset.parentOrigins || '').split(' ').filter(Boolean);
+ const framed = window.parent !== window && PARENTS.length > 0;
+ let parentSilent = false;
+ let asked = null;
let popup = null;
let popup = null;
+ const openPopup = () => {
+ 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');
+ };
for (const btn of $$('[data-drive]')) {
for (const btn of $$('[data-drive]')) {
btn.addEventListener('click', () => {
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');
+ if (!framed || parentSilent) return openPopup();
+ for (const origin of PARENTS) {
+ try { window.parent.postMessage({ type: 'ov-bot-ticket-request', robot_id: ROBOT_ID }, origin); } catch { /* not that origin */ }
+ }
+ notice('Asking for the controls…');
+ clearTimeout(asked);
+ asked = setTimeout(() => { parentSilent = true; notice('Press Drive again to sign in on openvibe.bot.'); }, 2500);
});
});
}
}
window.addEventListener('message', (e) => {
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;
+ // The popup this panel opened (this origin), or the allowed page around the frame; for this robot only.
+ const fromPopup = e.origin === location.origin && popup && e.source === popup;
+ const fromParent = framed && e.source === window.parent && PARENTS.includes(e.origin);
+ if (!fromPopup && !fromParent) return;
const d = e.data || {};
const d = e.data || {};
+ if (fromParent && d.type === 'ov-bot-ticket-none' && d.robot_id === ROBOT_ID) {
+ clearTimeout(asked);
+ notice(typeof d.message === 'string' ? d.message.slice(0, 200) : 'You cannot drive this robot from here.');
+ return;
+ }
if (d.type !== 'ov-bot-ticket' || d.robot_id !== ROBOT_ID || typeof d.ticket !== 'string') return;
if (d.type !== 'ov-bot-ticket' || d.robot_id !== ROBOT_ID || typeof d.ticket !== 'string') return;
+ clearTimeout(asked);
ticket = d.ticket; driving = true; watcher = false; gone = false;
ticket = d.ticket; driving = true; watcher = false; gone = false;
const bar = $('[data-drive-bar]');
const bar = $('[data-drive-bar]');
if (bar) bar.hidden = true;
if (bar) bar.hidden = true;
new file mode 100644
new file mode 100644
@@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
+/* OpenVibe.Bot — the framed "not shown here yet" page (server/web/render.js renderEmbedRefused): it tells the page around
+ the frame, only when that page is one of the allowed embedders, so a Live channel can hide the box from its viewers and
+ point its owner to "Put the buttons under my OpenVibe.Live stream". It says nothing but the robot id the page framed. */
+(() => {
+ const el = document.querySelector('[data-embed-refused]');
+ if (!el || window.parent === window) return;
+ const robotId = el.getAttribute('data-embed-refused');
+ for (const origin of String(el.getAttribute('data-parent-origins') || '').split(' ').filter(Boolean)) {
+ try { window.parent.postMessage({ type: 'ov-bot-embed-refused', robot_id: robotId }, origin); } catch { /* not that origin */ }
+ }
+})();
@@ -26,6 +26,7 @@
@@ -26,6 +26,7 @@
* | POST /robots/:id/estop | bot.robot.control | owner or operator |
* | POST /robots/:id/estop | bot.robot.control | owner or operator |
* | POST /robots/:id/estop/clear | bot.robot.control | the owner only |
* | POST /robots/:id/estop/clear | bot.robot.control | the owner only |
* | POST /robots/:id/commands | bot.robot.control | owner or operator |
* | POST /robots/:id/commands | bot.robot.control | owner or operator |
+ * | POST /robots/:id/control-ticket | bot.robot.control | a member (or anyone on a queue robot): a Drive ticket |
* | GET /robots/:id/audit | bot.robot.read | owner (paged) |
* | GET /robots/:id/audit | bot.robot.read | owner (paged) |
* | GET /robots/:id/streaming | bot.robot.read | a member |
* | GET /robots/:id/streaming | bot.robot.read | a member |
* | POST /robots/:id/streaming | bot.robot.manage | the owner |
* | POST /robots/:id/streaming | bot.robot.manage | the owner |
@@ -360,6 +361,24 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
@@ -360,6 +361,24 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
res.json({ robot: domain.present.robot(await domain.robots.get(req.params.id)) });
res.json({ robot: domain.present.robot(await domain.robots.get(req.params.id)) });
}));
}));
+ // ── A Drive ticket for an embedded panel (server/control-tickets.js) ───────────────────────────
+ // The page that frames a robot's panel (a Live channel bound to it) asks for a person who is signed in there: the
+ // frame cannot see an openvibe.bot session, and the popup Drive took (its own sign-in) was a step too many. The
+ // ticket names this person and robot, lasts two minutes and is used once; the join that redeems it still decides
+ // what they may do by their role, exactly as the popup's ticket.
+ r.post('/robots/:id/control-ticket', commandControl, wrap(async (req, res) => {
+ requireControlCap(req);
+ const subject = actedFor(req);
+ const robot = await domain.robots.get(req.params.id);
+ if (!robot) fail(404, 'bot.robot_not_found', 'no such robot');
+ const role = (await domain.members.roleOf(robot.id, subject)) || (robot.access_policy === 'queue' ? 'queue' : null);
+ if (!role) fail(403, 'bot.not_an_operator', 'the owner decides who may drive this robot');
+ const t = hub.issueControlTicket({ kind: 'user', subject }, robot.id);
+ if (!t) fail(503, 'bot.busy', 'try again in a moment');
+ res.setHeader('Cache-Control', 'no-store');
+ res.json({ ticket: t.ticket, robot_id: robot.id, role, expires_in: t.expires_in });
+ }));
+
// ── Commands (the /control gate over HTTP: a service forwards a bound channel's chat commands) ─
// ── Commands (the /control gate over HTTP: a service forwards a bound channel's chat commands) ─
// The same onOperatorCommand path as the /control socket — the gate, the audit and the per-subject
// The same onOperatorCommand path as the /control socket — the gate, the audit and the per-subject
// idempotency key are shared. `id` is that idempotency key; a repeated id is answered from the cache.
// idempotency key are shared. `id` is that idempotency key; a repeated id is answered from the cache.
@@ -55,6 +55,7 @@ const PATHS = {
@@ -55,6 +55,7 @@ const PATHS = {
robot: '<rect x="4" y="8" width="16" height="11" rx="2.5"/><path d="M12 4v4M9 13h.01M15 13h.01M2 13v2M22 13v2"/>',
robot: '<rect x="4" y="8" width="16" height="11" rx="2.5"/><path d="M12 4v4M9 13h.01M15 13h.01M2 13v2M22 13v2"/>',
plug: '<path d="M9 2v5M15 2v5M6 7h12v4a6 6 0 0 1-12 0zM12 17v5"/>',
plug: '<path d="M9 2v5M15 2v5M6 7h12v4a6 6 0 0 1-12 0zM12 17v5"/>',
gamepad: '<path d="M7 7h10a4 4 0 0 1 4 4l.8 5a2.6 2.6 0 0 1-4.6 2L15 15H9l-2.2 3a2.6 2.6 0 0 1-4.6-2L3 11a4 4 0 0 1 4-4z"/><path d="M7.5 10v3M6 11.5h3M16 11h.01M18 13h.01"/>',
gamepad: '<path d="M7 7h10a4 4 0 0 1 4 4l.8 5a2.6 2.6 0 0 1-4.6 2L15 15H9l-2.2 3a2.6 2.6 0 0 1-4.6-2L3 11a4 4 0 0 1 4-4z"/><path d="M7.5 10v3M6 11.5h3M16 11h.01M18 13h.01"/>',
+ check: '<path d="M20 6 9 17l-5-5"/>',
};
};
const icon = (name, cls = 'icon') => `<svg class="${cls}" viewBox="0 0 24 24" aria-hidden="true" focusable="false">${PATHS[name] || ''}</svg>`;
const icon = (name, cls = 'icon') => `<svg class="${cls}" viewBox="0 0 24 24" aria-hidden="true" focusable="false">${PATHS[name] || ''}</svg>`;
/** Hold-button icons by axis: [negative, positive]. */
/** Hold-button icons by axis: [negative, positive]. */
@@ -327,10 +328,31 @@ ${toggle('listed', robot.listed, 'List it in the robot directory', 'It gets a pu
@@ -327,10 +328,31 @@ ${toggle('listed', robot.listed, 'List it in the robot directory', 'It gets a pu
</form>${robot.listed ? `<p class="setting-note"><a href="/r/${esc(robot.id)}">Its public page</a></p>` : ''}</section>`;
</form>${robot.listed ? `<p class="setting-note"><a href="/r/${esc(robot.id)}">Its public page</a></p>` : ''}</section>`;
}
}
+/**
+ * "On your stream" (owner, 2026-10-26, from a streamer's notes: "impossible to put buttons on my stream"): one button
+ * puts this robot's panel under the owner's OpenVibe.Live stream (it shows the panel where it is embedded, then opens
+ * their channel, which asks to bind it), and who may drive from there is chosen beside it. Plain forms, no JavaScript.
+ */
+function onStreamCard(robot, { liveUrl = null } = {}) {
+ const policy = robot.access_policy || 'private';
+ const state = robot.embed_public
+ ? `<p class="setting-ok" role="status">${icon('check')}<span>Its panel shows wherever it is embedded. Bound to your channel, viewers see it under your stream.</span></p>`
+ : '';
+ return `<section class="setting-card on-stream" id="on-stream" aria-labelledby="on-stream-h"><h2 id="on-stream-h">On your stream</h2>
+<p class="setting-note">Put this robot's buttons under your OpenVibe.Live stream so your viewers can drive it there. Your stream stays the video; the panel adds the buttons, the e-stop and the latency.</p>
+${state}${liveUrl ? `<form method="post" action="/robots/${esc(robot.id)}/on-stream"><button type="submit" class="button primary">${icon('gamepad')}<span>Put the buttons under my OpenVibe.Live stream</span></button></form>
+<p class="setting-note">Opens your channel on OpenVibe.Live, which asks you to confirm. To take it off, choose no robot there, or turn off the panel below.</p>` : ''}
+<form class="access-form" method="post" action="/robots/${esc(robot.id)}/access">
+<label class="field"><span class="field-label">Who may drive</span><select name="access_policy">${POLICIES.map(([v, label]) => `<option value="${esc(v)}"${v === policy ? ' selected' : ''}>${esc(label)}</option>`).join('')}</select></label>
+<p class="setting-note">"Anyone, taking turns" lets every signed-in viewer queue for a turn. With the other two, only you and the people you add can drive; everyone else watches.</p>
+<button type="submit">Save</button>
+</form></section>`;
+}
+
/** The owner's embed switch: plain markup, works without JavaScript. On the panel it is a card; in the robots list, a compact row. */
/** The owner's embed switch: plain markup, works without JavaScript. On the panel it is a card; in the robots list, a compact row. */
function embedForm(robot, { card = true } = {}) {
function embedForm(robot, { card = true } = {}) {
const form = `<form class="embed-form${card ? '' : ' embed-inline'}" method="post" action="/robots/${esc(robot.id)}/embed" data-embed-form>
const form = `<form class="embed-form${card ? '' : ' embed-inline'}" method="post" action="/robots/${esc(robot.id)}/embed" data-embed-form>
-${toggle('embed_public', robot.embed_public, 'Let anyone watch where it is embedded', "A page that embeds this robot shows its video and readouts to everyone; never the controls.")}
+${toggle('embed_public', robot.embed_public, 'Show the panel where it is embedded (your OpenVibe.Live stream)', "Your stream's page and any other page that embeds this robot show its panel to everyone. Driving still needs who you allow below, through Drive.")}
<button type="submit">Save</button>
<button type="submit">Save</button>
</form>`;
</form>`;
return card ? `<section class="setting-card" aria-labelledby="embed-h"><h2 id="embed-h">Embedding</h2>\n${form}</section>` : form;
return card ? `<section class="setting-card" aria-labelledby="embed-h"><h2 id="embed-h">Embedding</h2>\n${form}</section>` : form;
@@ -358,7 +380,7 @@ function streamingForm(robot, streaming) {
@@ -358,7 +380,7 @@ function streamingForm(robot, streaming) {
<p class="setting-note">Off until you turn them on. Operators and viewers cannot change these.</p>
<p class="setting-note">Off until you turn them on. Operators and viewers cannot change these.</p>
${why}<form class="streaming-form" method="post" action="/robots/${esc(robot.id)}/streaming" data-streaming-form>
${why}<form class="streaming-form" method="post" action="/robots/${esc(robot.id)}/streaming" data-streaming-form>
${toggle('media', s.media && s.media.on, 'Record to OpenVibe.Media', 'Each time the robot goes live, OpenRestream records the session to OpenVibe.Media as an unlisted video.', off, true)}
${toggle('media', s.media && s.media.on, 'Record to OpenVibe.Media', 'Each time the robot goes live, OpenRestream records the session to OpenVibe.Media as an unlisted video.', off, true)}
-${toggle('live', s.live && s.live.on, 'Show on my OpenVibe.Live channel', 'Lets OpenRestream mirror the camera to your Live channel. It appears there once your channel plays OpenRestream streams.', off, true)}
+${toggle('live', s.live && s.live.on, "Send the robot's camera to my OpenVibe.Live channel", "Video only: OpenRestream mirrors the camera to your Live channel. For the buttons under your stream, use \"On your stream\" above.", off, true)}
<button type="submit"${off ? ' disabled' : ''}>Save</button>
<button type="submit"${off ? ' disabled' : ''}>Save</button>
</form></section>`;
</form></section>`;
}
}
@@ -441,7 +463,7 @@ function turnStrip(robot, role) {
@@ -441,7 +463,7 @@ function turnStrip(robot, role) {
return `<div class="turn" data-turn hidden><span class="turn-state" data-turn-state></span><span class="turn-meta" data-turn-meta></span>${leave}${give}</div>`;
return `<div class="turn" data-turn hidden><span class="turn-state" data-turn-state></span><span class="turn-meta" data-turn-meta></span>${leave}${give}</div>`;
}
}
-function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs = 150, mode = 'page', signedIn = true, streaming = null, people = null, signaling_url = null, profileForm = {}, tryIt = false, automation = null }) {
+function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs = 150, mode = 'page', signedIn = true, streaming = null, people = null, signaling_url = null, profileForm = {}, tryIt = false, automation = null, parentOrigins = [], liveUrl = null }) {
const embed = mode === 'embed';
const embed = mode === 'embed';
const latched = !!(robot.estop && robot.estop.latched);
const latched = !!(robot.estop && robot.estop.latched);
const widgets = (profile.widgets || []).map((w, i) => ({ w, i }))
const widgets = (profile.widgets || []).map((w, i) => ({ w, i }))
@@ -455,7 +477,7 @@ function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs
@@ -455,7 +477,7 @@ function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs
${embed ? '' : tryIt ? topbar('<span class="crumb">Try the panel</span>', '/') : topbar(`<span class="crumb">${esc(robot.name)}</span>`)}
${embed ? '' : tryIt ? topbar('<span class="crumb">Try the panel</span>', '/') : topbar(`<span class="crumb">${esc(robot.name)}</span>`)}
<div class="estop-bar"><p class="estop-state" role="status" aria-live="assertive"><span class="estop-dot"></span><span data-estop-state>${latched ? 'E-stop latched' : 'E-stop clear'}</span><span class="estop-note">${latched ? ' — the robot stays still until the owner clears it.' : ''}</span></p>${clear}${estop}</div>
<div class="estop-bar"><p class="estop-state" role="status" aria-live="assertive"><span class="estop-dot"></span><span data-estop-state>${latched ? 'E-stop latched' : 'E-stop clear'}</span><span class="estop-note">${latched ? ' — the robot stays still until the owner clears it.' : ''}</span></p>${clear}${estop}</div>
</header>
</header>
-<main id="panel" tabindex="0" data-robot-id="${esc(robot.id)}" data-role="${esc(role)}" data-hold-ms="${esc(holdResendMs)}" data-allowed="${data(allowed_commands)}"${tryIt ? ' data-transport="local"' : ''}>
+<main id="panel" tabindex="0" data-robot-id="${esc(robot.id)}" data-role="${esc(role)}" data-hold-ms="${esc(holdResendMs)}" data-allowed="${data(allowed_commands)}"${tryIt ? ' data-transport="local"' : ''}${embed && parentOrigins.length ? ` data-parent-origins="${esc(parentOrigins.join(' '))}"` : ''}>
<div class="panel-head">
<div class="panel-head">
<h1>${esc(robot.name)}</h1>
<h1>${esc(robot.name)}</h1>
<p class="meta"><span class="pill online" data-online-pill data-state="connecting"><span class="dot"></span><span data-online-label>Connecting…</span></span> <span class="pill">${esc(profile.name || robot.profile_id)}</span> <span class="pill role" data-role-label>${esc(ROLE_LABEL[role] || role)}</span></p>
<p class="meta"><span class="pill online" data-online-pill data-state="connecting"><span class="dot"></span><span data-online-label>Connecting…</span></span> <span class="pill">${esc(profile.name || robot.profile_id)}</span> <span class="pill role" data-role-label>${esc(ROLE_LABEL[role] || role)}</span></p>
@@ -468,7 +490,7 @@ ${embed && role === 'watcher' && !signedIn ? `<p class="embed-sign-in" data-driv
@@ -468,7 +490,7 @@ ${embed && role === 'watcher' && !signedIn ? `<p class="embed-sign-in" data-driv
<div class="widgets">
<div class="widgets">
${widgets.join('\n')}
${widgets.join('\n')}
</div>
</div>
-${tryIt ? tryOutro(profile) : ''}${role === 'owner' && !embed && !tryIt ? `<div class="owner-settings">${localProfileForm(robot, profile, profileForm)}${automation ? routinesCard(robot, profile, automation) + webhooksCard(robot, automation) : ''}${agentForm(robot)}${listingForm(robot)}${embedForm(robot)}${streamingForm(robot, streaming)}${people ? peopleCard(robot, people) : ''}</div>` : ''}
+${tryIt ? tryOutro(profile) : ''}${role === 'owner' && !embed && !tryIt ? `<div class="owner-settings">${onStreamCard(robot, { liveUrl })}${localProfileForm(robot, profile, profileForm)}${automation ? routinesCard(robot, profile, automation) + webhooksCard(robot, automation) : ''}${agentForm(robot)}${listingForm(robot)}${embedForm(robot)}${streamingForm(robot, streaming)}${people ? peopleCard(robot, people) : ''}</div>` : ''}
</main>`;
</main>`;
return page(tryIt ? 'Try the panel with a simulated rover' : robot.name, body, {
return page(tryIt ? 'Try the panel with a simulated rover' : robot.name, body, {
scripts: panelScripts(profile, tryIt), bodyClass: embed ? 'embed-page' : 'panel-page',
scripts: panelScripts(profile, tryIt), bodyClass: embed ? 'embed-page' : 'panel-page',
@@ -512,12 +534,14 @@ function renderDrivePage({ robot }) {
@@ -512,12 +534,14 @@ function renderDrivePage({ robot }) {
}
}
/** 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 }) {
- const body = `<main class="page embed-refused">
-<div class="card"><p>This robot is not shared for embedding.</p>
-<p>${outLink(`/panel/${robotId}`, 'Open on openvibe.bot', 'button primary')}</p></div>
+function renderEmbedRefused({ robotId, parentOrigins = [] }) {
+ const body = `<main class="page embed-refused" data-embed-refused="${esc(robotId)}" data-parent-origins="${esc(parentOrigins.join(' '))}">
+<div class="card"><p>This robot's panel is not shown here yet.</p>
+<p class="setting-note">Its owner turns it on with "Put the buttons under my OpenVibe.Live stream" on the robot's page.</p>
+<p>${outLink(`/panel/${robotId}#on-stream`, 'Open on openvibe.bot', 'button primary')}</p></div>
</main>`;
</main>`;
- return page('Not shared', body, { bodyClass: 'embed-page' });
+ // refused.js tells the page around the frame, so a stream page can hide the box or point its owner to the switch.
+ return page('Not shown here yet', body, { bodyClass: 'embed-page', scripts: ['/panel/refused.js'] });
}
}
/**
/**
@@ -15,6 +15,8 @@
@@ -15,6 +15,8 @@
* otherwise 403 with a link out. Never redirects to sign in; framed only by the
* otherwise 403 with a link out. Never redirects to sign in; framed only by the
* BOT_EMBED_ORIGINS allow-list (CSP frame-ancestors), every other page by itself
* BOT_EMBED_ORIGINS allow-list (CSP frame-ancestors), every other page by itself
* POST /robots/:id/embed the owner only: allow (embed_public=on) or stop (off) anonymous read-only embedding → 303 to its panel
* POST /robots/:id/embed the owner only: allow (embed_public=on) or stop (off) anonymous read-only embedding → 303 to its panel
+ * POST /robots/:id/on-stream the owner only: show the panel where it is embedded, then 303 to their Live channel's bind prompt
+ * POST /robots/:id/access the owner only: who may drive (private | invite | queue) → 303 to its panel
* POST /robots/:id/operators the owner only: add a person by @username with a role → 303 to its panel
* POST /robots/:id/operators the owner only: add a person by @username with a role → 303 to its panel
* POST /robots/:id/operators/:subject/remove the owner only: remove a person (never the owner row) → 303
* POST /robots/:id/operators/:subject/remove the owner only: remove a person (never the owner row) → 303
* POST /robots/:id/queue/leave any signed-in person: leave the robot's turn queue → 303
* POST /robots/:id/queue/leave any signed-in person: leave the robot's turn queue → 303
@@ -199,6 +201,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
@@ -199,6 +201,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
r.get('/panel/panel.css', asset('panel.css', 'text/css'));
r.get('/panel/panel.css', asset('panel.css', 'text/css'));
r.get('/panel/screen-share.js', asset('screen-share.js', 'application/javascript'));
r.get('/panel/screen-share.js', asset('screen-share.js', 'application/javascript'));
r.get('/panel/drive.js', asset('drive.js', 'application/javascript'));
r.get('/panel/drive.js', asset('drive.js', 'application/javascript'));
+ r.get('/panel/refused.js', asset('refused.js', 'application/javascript'));
r.get('/panel/sim-world.js', asset('sim-world.js', 'application/javascript'));
r.get('/panel/sim-world.js', asset('sim-world.js', 'application/javascript'));
r.get('/panel/sim-view.js', asset('sim-view.js', 'application/javascript'));
r.get('/panel/sim-view.js', asset('sim-view.js', 'application/javascript'));
r.get('/panel/try-device.js', asset('try-device.js', 'application/javascript'));
r.get('/panel/try-device.js', asset('try-device.js', 'application/javascript'));
@@ -811,6 +814,36 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
@@ -811,6 +814,36 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
res.redirect(303, `/panel/${robot.id}`);
res.redirect(303, `/panel/${robot.id}`);
}));
}));
+ // "Put the buttons under my OpenVibe.Live stream": show the panel where it is embedded, then open the owner's own Live
+ // channel with ?bind_robot=, where Live asks them to confirm the binding (Live's bot-bind.js). The username is the
+ // same on every site.
+ r.post('/robots/:id/on-stream', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {
+ const me = requireUser(req);
+ if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');
+ const robot = await domain.robots.get(req.params.id);
+ if (!robot) throw new BotError(404, 'bot.robot_not_found', 'no such robot');
+ if (robot.owner_subject !== me.subject) throw new BotError(403, 'bot.forbidden', 'only the owner may put it on a stream');
+ if (!robot.embed_public) await domain.robots.setEmbedPublic(robot.id, true);
+ const liveOrigin = (config.embed && config.embed.origins && config.embed.origins[0]) || null;
+ const username = req.viewerClaims && typeof req.viewerClaims.username === 'string' ? req.viewerClaims.username : '';
+ if (!liveOrigin || !username) return res.redirect(303, `/panel/${robot.id}#on-stream`);
+ return res.redirect(303, `${liveOrigin}/@${encodeURIComponent(username)}?bind_robot=${encodeURIComponent(robot.id)}`);
+ }));
+
+ // Who may drive (the robot's access policy), from the owner's page: it was only settable when a robot was made.
+ r.post('/robots/:id/access', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {
+ const me = requireUser(req);
+ if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');
+ const robot = await domain.robots.get(req.params.id);
+ if (!robot) throw new BotError(404, 'bot.robot_not_found', 'no such robot');
+ if (robot.owner_subject !== me.subject) throw new BotError(403, 'bot.forbidden', 'only the owner may choose who drives');
+ const policy = String((req.body && req.body.access_policy) || '');
+ if (!['private', 'invite', 'queue'].includes(policy)) throw new BotError(422, 'bot.invalid_policy', 'choose who may drive');
+ await domain.robots.update(robot.id, { access_policy: policy });
+ if (hub) hub.broadcast(robot.id);
+ res.redirect(303, `/panel/${robot.id}#on-stream`);
+ }));
+
r.post('/robots/:id/embed', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {
r.post('/robots/:id/embed', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {
const me = requireUser(req);
const me = requireUser(req);
if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');
if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');
@@ -927,9 +960,14 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
@@ -927,9 +960,14 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
for (const h of hooks) { const d = (await webhooks.recent(h.id))[0]; h.last = d ? `${d.state}${d.last_status ? ` (${d.last_status})` : ''}` : null; }
for (const h of hooks) { const d = (await webhooks.recent(h.id))[0]; h.last = d ? `${d.state}${d.last_status ? ` (${d.last_status})` : ''}` : null; }
automation = { routines: await routines.list(robot.id), hooks, events: webhooks.EVENTS, describe: (t) => routines.describeTrigger(typeof t === 'string' ? JSON.parse(t) : t), ...(profileForm.automation || {}) };
automation = { routines: await routines.list(robot.id), hooks, events: webhooks.EVENTS, describe: (t) => routines.describeTrigger(typeof t === 'string' ? JSON.parse(t) : t), ...(profileForm.automation || {}) };
}
}
+ const liveOrigin = (config.embed && config.embed.origins && config.embed.origins[0]) || null;
res.type('html').send(renderPanel({
res.type('html').send(renderPanel({
robot: presentForPage(robot), profile, role, mode, signedIn, streaming, people, signaling_url,
robot: presentForPage(robot), profile, role, mode, signedIn, streaming, people, signaling_url,
profileForm, automation,
profileForm, automation,
+ // The page that frames the embed may hand it a Drive ticket (public/panel.js); the owner's page offers to put
+ // the panel under their Live stream.
+ parentOrigins: mode === 'embed' ? ((config.embed && config.embed.origins) || []) : [],
+ liveUrl: role === 'owner' && mode === 'page' ? liveOrigin : null,
allowed_commands: role === 'watcher' ? [] : domain.control.allowedFor(robot, role, profile),
allowed_commands: role === 'watcher' ? [] : domain.control.allowedFor(robot, role, profile),
// A held control is re-sent well inside the device's deadline, so it never stops between two frames.
// A held control is re-sent well inside the device's deadline, so it never stops between two frames.
holdResendMs: Math.max(50, Math.min(HOLD_RESEND_MS, Math.floor(maxCommandMs / 2))),
holdResendMs: Math.max(50, Math.min(HOLD_RESEND_MS, Math.floor(maxCommandMs / 2))),
@@ -1057,7 +1095,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
@@ -1057,7 +1095,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
if (!robot) throw new BotError(404, 'bot.robot_not_found', 'no such robot');
if (!robot) throw new BotError(404, 'bot.robot_not_found', 'no such robot');
let role = req.viewer ? await memberRole(robot, req.viewer) : null;
let role = req.viewer ? await memberRole(robot, req.viewer) : null;
if (!role && robot.embed_public) role = 'watcher';
if (!role && robot.embed_public) role = 'watcher';
- if (!role) return res.status(403).type('html').send(renderEmbedRefused({ robotId: robot.id }));
+ if (!role) return res.status(403).type('html').send(renderEmbedRefused({ robotId: robot.id, parentOrigins: (config.embed && config.embed.origins) || [] }));
const profile = await profileOf(robot);
const profile = await profileOf(robot);
if (!profile) throw new BotError(404, 'bot.profile_not_found', 'this robot has no profile');
if (!profile) throw new BotError(404, 'bot.profile_not_found', 'this robot has no profile');
return sendPanel(res, robot, profile, role, 'embed', !!req.viewer);
return sendPanel(res, robot, profile, role, 'embed', !!req.viewer);
@@ -38,7 +38,9 @@ const { renderPanel } = require('../server/web/render');
@@ -38,7 +38,9 @@ const { renderPanel } = require('../server/web/render');
assert.ok(html.includes('data-drive>') && html.includes('data-drive-bar'));
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');
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');
const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'panel.js'), 'utf8');
- assert.match(js, /if \(e\.origin !== location\.origin \|\| !popup \|\| e\.source !== popup\) return;/);
+ // Only its own popup, or the allowed page around the frame (stream-buttons.test.js), hands it a ticket.
+ assert.match(js, /const fromPopup = e\.origin === location\.origin && popup && e\.source === popup;/);
+ assert.match(js, /if \(!fromPopup && !fromParent\) return;/);
assert.match(js, /new WebSocket\(wsUrl\(path\), \['ov\.ticket', `ov\.ticket\.\$\{ticket\}`\]\)/);
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');
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');
const popupJs = fs.readFileSync(path.join(__dirname, '..', 'public', 'drive.js'), 'utf8');
@@ -63,7 +63,7 @@ const { loadConfig, frameAncestors } = require('../server/config');
@@ -63,7 +63,7 @@ const { loadConfig, frameAncestors } = require('../server/config');
const html = await (await get(`/panel/${robot.id}`, alex)).text();
const html = await (await get(`/panel/${robot.id}`, alex)).text();
assert.match(html, new RegExp(`action="/robots/${robot.id}/embed"`));
assert.match(html, new RegExp(`action="/robots/${robot.id}/embed"`));
assert.match(html, /name="embed_public" value="on" checked/);
assert.match(html, /name="embed_public" value="on" checked/);
- assert.match(html, /never the controls/);
+ assert.match(html, /Driving still needs who you allow/);
const r = await t.call('GET', `/api/v1/robots/${robot.id}`, { user: alex });
const r = await t.call('GET', `/api/v1/robots/${robot.id}`, { user: alex });
assert.ok(!r.text.includes('embed_public'));
assert.ok(!r.text.includes('embed_public'));
const off = await post(url, alex, { embed_public: 'off' });
const off = await post(url, alex, { embed_public: 'off' });
@@ -84,7 +84,7 @@ const { renderPanel } = require('../server/web/render');
@@ -84,7 +84,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('Private rover') && !html.includes('data-robot-id'));
assert.ok(!html.includes('Private rover') && !html.includes('data-robot-id'));
- assert.match(html, /href="\/panel\/rob_[0-9A-Za-z]+" target="_blank" rel="noopener">Open on openvibe\.bot</);
+ assert.match(html, /href="\/panel\/rob_[0-9A-Za-z]+#on-stream" target="_blank" rel="noopener">Open on openvibe\.bot</, 'to the owner\'s "On your stream" card');
assert.strictEqual((await get(`/panel/${closed.id}/embed`, bob)).status, 403);
assert.strictEqual((await get(`/panel/${closed.id}/embed`, bob)).status, 403);
assert.strictEqual((await get('/panel/rob_nothing/embed')).status, 404);
assert.strictEqual((await get('/panel/rob_nothing/embed')).status, 404);
const owner = await get(`/panel/${closed.id}/embed`, alex);
const owner = await get(`/panel/${closed.id}/embed`, alex);
new file mode 100644
new file mode 100644
@@ -0,0 +1,120 @@
@@ -0,0 +1,120 @@
+'use strict';
+// Buttons under a Live stream, from a streamer's notes (Maticus, 2026-10-25/26: "impossible to put buttons on my
+// stream", "the embedding toggle is off by default", "binding is a separate step and nothing points you to it", "who may
+// drive is API-only", "driving takes an extra popup and sign-in"):
+// - The owner's page leads with "On your stream": one button shows the panel where it is embedded and opens their Live
+// channel's bind prompt; who may drive is chosen beside it. Others cannot do either.
+// - POST /api/v1/robots/:id/control-ticket: the page around the frame (Live, a service with bot.robot.control) gets a
+// Drive ticket for a person signed in there: a member, or anyone on a queue robot; the ticket opens /control.
+// - The embedded panel asks the allowed page around it for that ticket (and takes it only from there or its own popup);
+// the framed refusal tells that page, so it can hide the box and point the owner to the switch.
+// - The wording says what each switch does (the camera mirror is video only).
+const fs = require('fs');
+const path = require('path');
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/app');
+const { renderPanel, renderEmbedRefused } = require('../server/web/render');
+
+(async () => {
+ const t = await boot();
+ const alex = t.network.newUser('alex');
+ const bob = t.network.newUser('bob');
+ const cookie = (user) => ({ Cookie: `ov_token=${t.network.signUser(user)}` });
+ const get = (p, user) => fetch(t.base + p, { redirect: 'manual', headers: user ? cookie(user) : {} });
+ const post = (p, user, form, headers = {}) => fetch(t.base + p, {
+ method: 'POST', redirect: 'manual', body: new URLSearchParams(form).toString(),
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...(user ? cookie(user) : {}), ...headers },
+ });
+ const row = async (id) => t.db.maybe('SELECT embed_public, access_policy FROM robots WHERE id = $1', [id]);
+
+ const { robot } = await t.robot(alex, { name: 'Cozmo Hobo' });
+ const { robot: open } = await t.robot(alex, { name: 'Queue rover', access_policy: 'queue' });
+
+ await check('the owner\'s page leads with "On your stream": the button, who may drive, the plain wording', async () => {
+ const page = await (await get(`/panel/${robot.id}`, alex)).text();
+ const card = page.indexOf('id="on-stream"');
+ assert.ok(card > 0 && card < page.indexOf('Buttons'), 'the first owner card');
+ assert.ok(page.includes(`action="/robots/${robot.id}/on-stream"`) && page.includes('Put the buttons under my OpenVibe.Live stream'));
+ assert.ok(page.includes(`action="/robots/${robot.id}/access"`) && page.includes('<option value="private" selected>Only me and people I add</option>'));
+ assert.ok(page.includes('Show the panel where it is embedded (your OpenVibe.Live stream)'), 'the embedding switch says where');
+ assert.ok(!page.includes('Let anyone watch where it is embedded'));
+ const other = await (await get(`/panel/${open.id}`, bob)).text();
+ assert.ok(!other.includes('id="on-stream"'), 'only the owner sees it');
+ });
+
+ await check('"Put the buttons under my stream" shows the panel where embedded and opens the Live bind prompt', async () => {
+ assert.strictEqual((await row(robot.id)).embed_public, false);
+ const r = await post(`/robots/${robot.id}/on-stream`, alex, {});
+ assert.strictEqual(r.status, 303);
+ assert.strictEqual(r.headers.get('location'), `https://openvibe.live/@alex?bind_robot=${robot.id}`);
+ assert.strictEqual((await row(robot.id)).embed_public, true);
+ assert.strictEqual((await post(`/robots/${robot.id}/on-stream`, bob, {})).status, 403, 'not the owner');
+ assert.strictEqual((await post(`/robots/${robot.id}/on-stream`, alex, {}, { Origin: 'https://evil.example' })).status, 403, 'cross-site');
+ const page = await (await get(`/panel/${robot.id}`, alex)).text();
+ assert.ok(page.includes('class="setting-ok"'), 'the card says it is on');
+ });
+
+ await check('who may drive: the owner chooses it on the page', async () => {
+ const r = await post(`/robots/${robot.id}/access`, alex, { access_policy: 'queue' });
+ assert.strictEqual(r.status, 303);
+ assert.strictEqual(r.headers.get('location'), `/panel/${robot.id}#on-stream`);
+ assert.strictEqual((await row(robot.id)).access_policy, 'queue');
+ assert.strictEqual((await post(`/robots/${robot.id}/access`, alex, { access_policy: 'everyone' })).status, 422);
+ assert.strictEqual((await post(`/robots/${robot.id}/access`, bob, { access_policy: 'private' })).status, 403);
+ await post(`/robots/${robot.id}/access`, alex, { access_policy: 'private' });
+ assert.strictEqual((await row(robot.id)).access_policy, 'private');
+ });
+
+ await check('POST /api/v1/robots/:id/control-ticket: for a member or a queue robot, as the person; a ticket that drives', async () => {
+ const forPerson = (id, user, cap = ['bot.robot.control']) => t.call('POST', `/api/v1/robots/${id}/control-ticket`, { cap, headers: user ? { 'X-OV-Subject': user.subject } : {} });
+ const mine = await forPerson(robot.id, alex);
+ assert.strictEqual(mine.status, 200, mine.text);
+ assert.match(mine.json.ticket, /^[A-Za-z0-9_-]{43}$/);
+ assert.deepStrictEqual([mine.json.robot_id, mine.json.role], [robot.id, 'owner']);
+ assert.strictEqual(mine.headers.get('cache-control'), 'no-store');
+ const stranger = await forPerson(robot.id, bob);
+ assert.deepStrictEqual([stranger.status, stranger.json.code], [403, 'bot.not_an_operator'], 'a private robot');
+ const queue = await forPerson(open.id, bob);
+ assert.deepStrictEqual([queue.status, queue.json.role], [200, 'queue'], 'anyone signed in on a queue robot');
+ assert.strictEqual((await forPerson(robot.id, alex, ['bot.robot.read'])).status, 403, 'needs bot.robot.control');
+ assert.strictEqual((await forPerson(robot.id, null)).status, 422, 'a service must name the person');
+ assert.strictEqual((await t.call('POST', `/api/v1/robots/${robot.id}/control-ticket`, { user: bob })).status, 403, 'a person asking for their own, on a private robot');
+ // The ticket opens /control as that person.
+ const ws = await t.ws('/control', { protocols: ['ov.ticket', `ov.ticket.${mine.json.ticket}`] });
+ ws.send({ type: 'join', robot_id: robot.id });
+ const joined = await ws.waitFor((m) => m.type === 'joined' || m.type === 'error');
+ assert.deepStrictEqual([joined.type, joined.role], ['joined', 'owner'], JSON.stringify(joined));
+ ws.close();
+ });
+
+ await check('the embedded panel asks its allowed page for a ticket; the refusal tells that page', () => {
+ 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', mode: 'embed', signedIn: false, parentOrigins: ['https://openvibe.live', 'https://www.openvibe.live'] });
+ assert.ok(html.includes('data-parent-origins="https://openvibe.live https://www.openvibe.live"'));
+ const page = renderPanel({ robot: { id: 'rob_d1', name: 'Rover', profile_id: 'sim.rover', estop: { latched: false } }, profile: SIM, role: 'watcher', mode: 'page', parentOrigins: ['https://openvibe.live'] });
+ assert.ok(!page.includes('data-parent-origins'), 'only a framed panel');
+ const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'panel.js'), 'utf8');
+ assert.ok(js.includes("window.parent.postMessage({ type: 'ov-bot-ticket-request', robot_id: ROBOT_ID }, origin)"), 'asks each allowed origin only');
+ assert.ok(js.includes('const fromParent = framed && e.source === window.parent && PARENTS.includes(e.origin);'), 'takes it from that page only');
+ assert.ok(js.includes('const fromPopup = e.origin === location.origin && popup && e.source === popup;'), 'or from its own popup');
+ const refused = renderEmbedRefused({ robotId: 'rob_d1', parentOrigins: ['https://openvibe.live'] });
+ assert.ok(refused.includes('src="/panel/refused.js"') && refused.includes('data-parent-origins="https://openvibe.live"'));
+ assert.ok(refused.includes('/panel/rob_d1#on-stream') && refused.includes('Put the buttons under my OpenVibe.Live stream'));
+ const rjs = fs.readFileSync(path.join(__dirname, '..', 'public', 'refused.js'), 'utf8');
+ assert.ok(rjs.includes("window.parent.postMessage({ type: 'ov-bot-embed-refused', robot_id: robotId }, origin)"));
+ });
+
+ await check('/panel/refused.js is served', async () => {
+ const r = await get('/panel/refused.js');
+ assert.strictEqual(r.status, 200);
+ assert.match(r.headers.get('content-type'), /javascript/);
+ });
+
+ await check('the camera mirror says it is video only', async () => {
+ const page = await (await get(`/panel/${robot.id}`, alex)).text();
+ assert.ok(page.includes("Send the robot's camera to my OpenVibe.Live channel") || page.includes("Send the robot's camera to my OpenVibe.Live channel"));
+ });
+
+ await t.close();
+ done();
+})();
@@ -171,7 +171,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -171,7 +171,7 @@ const { boot, check, done } = require('./helpers/app');
let html = await (await page(`/panel/${robot.id}`, alex)).text();
let html = await (await page(`/panel/${robot.id}`, alex)).text();
assert.match(html, new RegExp(`action="/robots/${robot.id}/streaming"`));
assert.match(html, new RegExp(`action="/robots/${robot.id}/streaming"`));
assert.match(html, /Record to OpenVibe\.Media/);
assert.match(html, /Record to OpenVibe\.Media/);
- assert.match(html, /Show on my OpenVibe\.Live channel/);
+ assert.match(html, /Send the robot('|')s camera to my OpenVibe\.Live channel/, 'video only; the buttons are "On your stream"');
assert.doesNotMatch(html, /name="(media|live)" value="on" checked/, 'both off by default');
assert.doesNotMatch(html, /name="(media|live)" value="on" checked/, 'both off by default');
const saved = await form(`/robots/${robot.id}/streaming`, alex, 'media_was=off&media=off&media=on&live_was=off&live=off');
const saved = await form(`/robots/${robot.id}/streaming`, alex, 'media_was=off&media=off&media=on&live_was=off&live=off');
assert.strictEqual(saved.status, 303);
assert.strictEqual(saved.status, 303);