openvibers/openvibe.bot Commits
Add the robot's streaming and recording switches, off by default (record to Media, show on Live) (#29)
Changes
@@@ -14,6 +14,7 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato- The `/device` and `/control` WebSockets, the command gate and the latched e-stop.- The signed-in pages (`server/web/routes.js`): `/robots` to list and add robots, `/pair/:id` for a pairing code and the installer command, and `/panel/:id`, the panel rendered from the profile. A `sim.rover` robot is driven by an in-process simulator (`server/sim`), so the panel works before any hardware exists. It gets no jobs, and it is not counted as a device or reported online in the outbox. The panel (`public/panel.js`, no build step) drives with a touch joystick, the keyboard or a gamepad, shows a latency meter and one camera tile per camera the profile lists; the camera stays a placeholder until OpenRe.Stream can play a WHIP-published stream back to a browser.- The command audit (`command_audit`, kept 30 days) and the `bot.*` outbox events.++- The robot's streaming and recording toggles, off by default: `media` records live sessions to OpenVibe.Media and `live` shows them on the owner's OpenVibe.Live channel. They live only on the robot's OpenRe stream (`recording_mode`, `mirror_to_live`); Bot stores no copy and the owner, not an operator, flips them (`GET`/`POST /api/v1/robots/:id/streaming`).- Dispatching `platform.job@1` jobs to a paired Node over `/device` (`server/jobs/index.js` over `server/jobs/dispatch.js`; the internal `bot.job.dispatch` API is `POST /api/v1/jobs`, `POST /jobs/:id/cancel`, `GET /jobs/:id`) and metering them per wall-clock second (`run_jobs`, `run_usage_outbox`; docs/protocol.md §1.2).## Does not own@@@ -41,8 +42,8 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato| capability | guards ||---|---|--| `bot.robot.read` | `GET /robots`, `GET /robots/:id`, operators, devices and the audit, for a service acting for an owner |--| `bot.robot.manage` | create, patch and delete a robot, pairing codes, operators |++| `bot.robot.read` | `GET /robots`, `GET /robots/:id`, operators, devices, the audit and the streaming toggles, for a service acting for an owner |++| `bot.robot.manage` | create, patch and delete a robot, pairing codes, operators, and the streaming toggles (`POST /robots/:id/streaming`) || `bot.robot.control` | the e-stop set and clear (clear is owner-only), the control gate and `POST /robots/:id/commands` (so a bound channel's chat can forward a command over HTTP instead of a hardware socket) || `bot.device.connect` | rotate and revoke a device credential || `bot.job.dispatch` | the internal Run → Bot jobs API (`POST /jobs`, `POST /jobs/:id/cancel`, `GET /jobs/:id`); services only |@@@ -75,7 +76,7 @@ The embeddable panel is `GET /panel/:id/embed` (framed only by `BOT_EMBED_ORIGINSet `BOT_WHIP_BASE` in `/etc/openvibe/bot.env` to the WHIP ingest base each device publishes to (`whip_url = <base>/<publish key>`, sent once with the pairing; OpenRe's is `https://ingest.openre.stream/whip`); leave it unset to pair devices without video.--The publish key is always an ingest key OpenRe.Stream issued for the robot's stream (OpenRe's WHIP ingest admits no other): set `BOT_OPENRE_URL` and `BOT_OPENRE_TOKEN` (a Network service token holding `openre.stream.read`, `openre.stream.write` and `openre.key.rotate`, or `openre.stream.*` with `openre.key.rotate`; `BOT_OPENRE_TIMEOUT_MS`, default 8000, bounds each call). Pairing creates the robot's OpenRe stream (or rotates the one it has), a credential rotation rotates its key, revoking a device or removing a robot revokes the key and ends the live session. Bot stores the stream id and the key's hint, never the key. With either variable unset, devices still pair but get no publish key and the answer says `"video": "not_configured"`; Bot mints no key of its own.++The publish key is always an ingest key OpenRe.Stream issued for the robot's stream (OpenRe's WHIP ingest admits no other): set `BOT_OPENRE_URL` and `BOT_OPENRE_TOKEN` (a Network service token holding `openre.stream.read`, `openre.stream.write` and `openre.key.rotate`, or `openre.stream.*` with `openre.key.rotate`; `BOT_OPENRE_TIMEOUT_MS`, default 8000, bounds each call). The same token covers the streaming toggles: `GET /api/v1/robots/:id/streaming` reads the stream with `openre.stream.read`, `POST` PATCHes `recording_mode`/`mirror_to_live` with `openre.stream.write`. Pairing creates the robot's OpenRe stream (or rotates the one it has), a credential rotation rotates its key, revoking a device or removing a robot revokes the key and ends the live session. Bot stores the stream id and the key's hint, never the key. With either variable unset, devices still pair but get no publish key and the answer says `"video": "not_configured"`; Bot mints no key of its own.`/install` is proxied to the app, which answers a 302 to OpenVibe.Node's installer script (`BOT_INSTALLER_SOURCE_URL`, by default the `install.sh` asset on OpenVibe.Node's [latest release](https://github.com/OpenVibers/OpenVibe.Node/releases/latest/download/) — a released, checksummed installer that never drifts with Node's `main`, whose CI keeps `dist/install.sh` byte-identical to `install/install.sh`; https on an allow-listed GitHub or openvibe.bot host, checked at boot, and no query parameter changes the target). The pairing's installer command adds `--driver adeept|adeept-mecanum|cozmo` for those profiles and nothing for the others (the dry-run `none`).@@@ -41,7 +41,8 @@"panel input: a touch joystick, the keyboard and a gamepad (Gamepad API), each held control re-sent while held and zeroed at once on release; a latency meter (round-trip median and telemetry age); one camera tile per camera the profile lists (a placeholder until OpenRe can play a WHIP stream back); a live online indicator on /pair/:id read from /control","platform.job@1 jobs dispatched to a paired Node over /device (platform.job-frame@1: resent unacked on reconnect, only to an advertised runtime class) and metered per second into run_usage_outbox for billing.usage.record","embed opt-in: robots.embed_public (migration 0005), set only by the owner with POST /robots/:id/embed, web-only (not in /api/v1); BOT_EMBED_ORIGINS is the validated frame-ancestors allow-list for the embeddable panel (default OpenVibe.Live)",-- "embeddable panel: GET /panel/:id/embed framed only by BOT_EMBED_ORIGINS (no cookie, never a sign-in redirect; a member keeps their role, anyone else watches an embed_public robot) and the anonymous read-only /watch socket carrying the public state (online, e-stop, latency, battery, the profile's readouts), capped per address and per robot"++ "embeddable panel: GET /panel/:id/embed framed only by BOT_EMBED_ORIGINS (no cookie, never a sign-in redirect; a member keeps their role, anyone else watches an embed_public robot) and the anonymous read-only /watch socket carrying the public state (online, e-stop, latency, battery, the profile's readouts), capped per address and per robot",++ "streaming and recording toggles per robot, off by default and owner-only: media records live sessions to OpenVibe.Media (the OpenRe stream's recording_mode vod/none) and live shows them on the owner's OpenVibe.Live channel (mirror_to_live), read at GET /api/v1/robots/:id/streaming and set at POST /api/v1/robots/:id/streaming; Bot stores no copy, each real change audits once"],"updated": "2026-10-04"}@@@ -121,7 +121,10 @@ pairing with `BOT_OPENRE_URL`/`BOT_OPENRE_TOKEN` set and found again by that refA pairing or `POST /devices/bind` rotates the stream's key with no grace (the robot's previous device stops publishing), acredential rotation with the credential's grace, a revocation with no grace and the live session ended;removing the robot does the same and archives the stream when nothing is live. Bot stores the stream id and--the key's hint, never the key. OpenRe refusing is `502 bot.openre_refused` (its problem code in `detail`),++the key's hint, never the key. The same token carries the streaming toggles: `GET /robots/:id/streaming`++reads the stream (`openre.stream.read`) and `POST` PATCHes `recording_mode`/`mirror_to_live`++(`openre.stream.write`); Bot keeps neither, the OpenRe stream is the only copy.++OpenRe refusing is `502 bot.openre_refused` (its problem code in `detail`),not answering `503 bot.openre_unavailable`; a pairing that fails so leaves the code unused and no device.With `BOT_OPENRE_URL` or `BOT_OPENRE_TOKEN` unset the answers carry `"video": "not_configured"` and neither`publish_key` nor `whip_url`.@@@ -429,7 +432,7 @@ the subject it acts for). Without a token, every route but `GET /profiles`, `GETand `POST /devices/bind` answers `401 bot.sign_in`: `GET|POST /robots`, `GET|PATCH|DELETE /robots/:id`,`POST /robots/:id/pairing-code`, `GET|POST /robots/:id/operators`, `DELETE /robots/:id/operators/:subject`,`GET /robots/:id/devices`, `POST /devices/:id/rotate|revoke`, `POST /robots/:id/estop`,--`POST /robots/:id/estop/clear`, `POST /robots/:id/commands` and `GET /robots/:id/audit`. A node token gets `403 bot.forbidden` on the same++`POST /robots/:id/estop/clear`, `POST /robots/:id/commands`, `GET /robots/:id/audit` and `GET|POST /robots/:id/streaming`. A node token gets `403 bot.forbidden` on the sameroutes. Both come before any lookup, so a real robot or device id answers exactly as a made-up one. The threejobs routes are service-only: the same `401 bot.sign_in` without a token, `403 bot.forbidden` for a person or anode token, and a service token is judged on `bot.job.dispatch` alone.@@@ -454,6 +457,8 @@ node token, and a service token is judged on `bot.job.dispatch` alone.| `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 || `GET /robots/:id/audit?limit=&before=` | owner, or `bot.robot.read` | `{ audit[], next_before }` (newest first) |++| `GET /robots/:id/streaming` | a member, or `bot.robot.read` | `{ available, media{on}, live{on,effective}, stream_id }` (plus `reason` when unavailable), read from the robot's OpenRe stream; `available:false` is `not_configured` (OpenRe unset), `not_paired` (no stream yet) or `stream_missing` (OpenRe answered 404) |++| `POST /robots/:id/streaming` | **the owner only**, or `bot.robot.manage` | body `{ to: "media"\|"live", on: bool }` → the new state (as `GET`). `media` sets the stream's `recording_mode` `vod`/`none` (record to OpenVibe.Media); `live` sets `mirror_to_live` (show on the owner's OpenVibe.Live channel). One audit row per real change (`streaming.media`/`streaming.live`); an idempotent repeat answers the state and writes none. `422 bot.invalid_streaming`; `409 bot.not_paired` / `bot.openre_not_configured` || `POST /jobs`, `POST /jobs/:id/cancel`, `GET /jobs/:id` | **a service only**, with `bot.job.dispatch` (Run → Bot; a person or a node token is `403 bot.forbidden`) | `201 { job, sent }` / `{ job, sent }` / `{ job, stdout }`; `422 bot.invalid_input` without `project_id`, `409 bot.class_unadvertised` when the device does not advertise the job's class, `404 bot.job_not_found` || `POST /pair` | the one-time code is the credential | `201 { device_id, credential, publish_key, whip_url, robot_id, profile }`; with `BOT_PAIRING_AUTHORITY=network` always `410 bot.pairing_moved` (the `detail` names the Network URL) |@@@ -469,6 +474,8 @@ and the signed-in pages `GET|POST /robots`, `GET /pair/:id`, `GET /panel/:id` anEvery command — allowed or refused — is written to `command_audit` (robot, device, the operatorprincipal and its kind, role, kind, the clamped value summary, result `ack|nack|refused|expired`,--reason, latency, time), kept 30 days. The outbox publishes `bot.robot.online`, `bot.robot.offline`,++reason, latency, time), kept 30 days. Changing a robot's streaming toggles writes one row per real change++(`kind` `streaming.media` or `streaming.live`, `value` `{ on }`, role `owner`, result `ack`); an++idempotent repeat writes none. The outbox publishes `bot.robot.online`, `bot.robot.offline`,`bot.estop.set`, `bot.estop.cleared` and `bot.command.refused` — small payloads of ids, kinds andresults, never a credential, a pairing code, a publish key or a WHIP URL.@@@ -203,3 +203,20 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf.installer { margin: 0 0 0.75rem; overflow-x: auto; padding: 0.85rem; background: var(--bg); border: 1px solid var(--line); border-radius: 10px; font-size: 0.85rem; }.copy[data-copied="true"] { border-color: var(--ok); color: var(--ok); }.code { font-family: ui-monospace, monospace; font-size: 1.75rem; letter-spacing: 0.15em; }++++/* The owner's settings under the panel: embedding and the streaming switches, one card each. */++.owner-settings { display: grid; gap: 0.75rem; margin-top: 0.75rem; grid-template-columns: minmax(0, 1fr); }++@media (min-width: 900px) { .owner-settings { grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: start; } }++.setting-card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: 0.9rem; min-width: 0; }++.setting-card form { display: grid; gap: 0.6rem; }++.setting-card button[type="submit"] { justify-self: start; }++.setting-note { color: var(--muted); font-size: 0.88rem; margin: -0.35rem 0 0.7rem; }++.setting-note a { color: var(--accent); }++.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); }++.toggle:has(input:disabled) { cursor: not-allowed; }++.toggle input { width: 1.25rem; height: 1.25rem; min-width: 0; min-height: 0; margin: 0.12rem 0 0; accent-color: var(--accent); }++.toggle b { display: block; font-weight: 600; }++.toggle small { display: block; margin-top: 0.15rem; color: var(--muted); font-size: 0.85rem; line-height: 1.4; }++.toggle:has(input:disabled) b, .toggle:has(input:disabled) small { opacity: 0.55; }@@@ -24,6 +24,8 @@* | POST /robots/:id/estop/clear | bot.robot.control | the owner only |* | POST /robots/:id/commands | bot.robot.control | owner or operator |* | 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 |* | POST /jobs, /jobs/:id/cancel, GET /jobs/:id | bot.job.dispatch | — (services only) |* | POST /pair | — (one-time code) | the agent (code is the credential) |*@@@ -221,6 +223,19 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config }) {res.json({ device: domain.present.device(revoked) });}));++ // ── Streaming (the owner's OpenRe toggles, read from OpenRe; Bot stores no copy) ───────────────++ // Reading is a member read, like GET /robots/:id. Changing is the owner's alone (`manage`, not++ // `control`): whether a robot's camera is recorded or shown on the owner's channel is their call.++ r.get('/robots/:id/streaming', wrap(async (req, res) => {++ await member(req, req.params.id);++ res.json(await domain.streaming.get(req.params.id));++ }));++ r.post('/robots/:id/streaming', manage, wrap(async (req, res) => {++ const robot = await owner(req, req.params.id);++ const subject = req.principal.kind === 'user' ? me(req) : userSubject(req.headers['x-ov-subject'] || req.body?.owner, 'subject');++ res.json(await domain.streaming.set(req.params.id, { subject, kind: req.principal.kind }, { to: req.body?.to, on: req.body?.on }));++ }));++// ── E-stop ────────────────────────────────────────────────────────────────────────────────────r.post('/robots/:id/estop', control, wrap(async (req, res) => {const { robot } = await member(req, req.params.id, ['owner', 'operator']);@@@ -332,6 +332,60 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =const clean = base ? String(base).replace(/\/+$/, '') : '';return clean && publishKey ? `${clean}/${encodeURIComponent(publishKey)}` : null;}++ // ── Streaming toggles (the owner's OpenRe stream is the single source of truth) ────────────────++ // Bot stores no copy: `media` is the stream's recording_mode ('vod' on, 'none' off) and `live` its++ // mirror_to_live. `live` is the owner's consent, but OpenRe mirrors a session into their Live channel++ // only once Live plays OpenRe streams for that channel, so the answer always carries `effective`.++ const LIVE_EFFECTIVE = 'when your Live channel plays OpenRe streams';++ const streamingState = (robot, stream) => ({++ available: true,++ media: { on: stream.recording_mode === 'vod' },++ live: { on: !!stream.mirror_to_live, effective: LIVE_EFFECTIVE },++ stream_id: robot.openre_stream_id,++ });++ /** The offline shape: nothing to read, with why — OpenRe unset, no stream yet, or OpenRe lost it. */++ const streamingUnavailable = (robot, reason) => ({++ available: false, reason, stream_id: robot.openre_stream_id || null,++ media: { on: false }, live: { on: false, effective: LIVE_EFFECTIVE },++ });++ /**++ * The robot's streaming toggles, read from its OpenRe stream. `available: false` with reason++ * 'not_configured' (OpenRe unset), 'not_paired' (the robot has no stream yet) or 'stream_missing'++ * (OpenRe answers 404).++ */++ async function streaming(robotId) {++ const robot = await getRobot(robotId);++ if (!robot) fail(404, 'bot.robot_not_found', 'no such robot');++ if (!openre) return streamingUnavailable(robot, 'not_configured');++ if (!robot.openre_stream_id) return streamingUnavailable(robot, 'not_paired');++ const stream = await openre.get(robot.openre_stream_id, robot.owner_subject);++ if (!stream) return streamingUnavailable(robot, 'stream_missing');++ return streamingState(robot, stream);++ }++ /**++ * Turn `media` (recording_mode 'vod'/'none') or `live` (mirror_to_live) on or off on the robot's OpenRe++ * stream, and audit the change once (kind streaming.media/streaming.live, value { on }). Idempotent:++ * setting the value it already has answers the state and writes no audit row.++ */++ async function setStreaming(robotId, actor, { to, on } = {}) {++ const robot = await getRobot(robotId);++ if (!robot) fail(404, 'bot.robot_not_found', 'no such robot');++ if (!['media', 'live'].includes(to) || typeof on !== 'boolean') fail(422, 'bot.invalid_streaming', "to must be 'media' or 'live' and on must be true or false");++ if (!openre) fail(409, 'bot.openre_not_configured', 'OpenRe is not configured here');++ if (!robot.openre_stream_id) fail(409, 'bot.not_paired', 'the robot has no OpenRe stream yet; pair a device first');++ const current = await openre.get(robot.openre_stream_id, robot.owner_subject);++ if (!current) fail(409, 'bot.not_paired', 'OpenRe no longer has the robot\'s stream; pair a device again');++ const already = to === 'media' ? current.recording_mode === 'vod' : !!current.mirror_to_live;++ if (already === on) return streamingState(robot, current);++ const fields = to === 'media' ? { recording_mode: on ? 'vod' : 'none' } : { mirror_to_live: on };++ const updated = await openre.update(robot.openre_stream_id, fields, robot.owner_subject);++ if (!updated) fail(409, 'bot.not_paired', 'OpenRe no longer has the robot\'s stream; pair a device again');++ await auditCommand({++ robotId, subject: actor && actor.subject, operatorKind: actor && actor.kind, role: 'owner',++ kind: `streaming.${to}`, value: { on }, result: 'ack',++ });++ return streamingState(robot, updated);++ }async function prunePairingCodes() {await db.query('DELETE FROM pairing_codes WHERE expires_at < $1', [iso(now() - 24 * 3600 * 1000)]);}@@@ -793,6 +847,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =members: { roleOf, add: addOperator, remove: removeOperator, list: listOperators },pairing: { create: createPairingCode, redeem, prune: prunePairingCodes, installerCommand, driverForProfile, whipUrl },devices: { byCredential, bindNode, issuePublishKey, revokeVideo, updateDeclared, revokeNode, get: getDevice, listForRobot: listDevicesForRobot, rotate: rotateDevice, revoke: revokeDevice, touchSeen, setOnline },++ streaming: { get: streaming, set: setStreaming },estop: { set: setEstop, clear: clearEstop },queue: { join: joinQueue, state: queueState, currentTurn, consume: consumeTurn, sweep: sweepQueues },audit: { record: auditCommand, list: listAudit, listPage: listAuditPage, prune: pruneAudit },@@@ -11,13 +11,15 @@* (X-OV-Subject), so OpenRe limits them to that owner's streams.** find(ref, owner) GET /api/v1/streams?external_ref=… → the stream, or null++ * get(id, owner) GET /api/v1/streams/:id → the stream, or null on 404* create(body, owner) POST /api/v1/streams → { stream, key: { id, key, hint } }++ * update(id, fields, owner) PATCH /api/v1/streams/:id → the updated stream, or null on 404* rotate(id, owner, { grace_seconds, end_sessions })* POST /api/v1/streams/:id/keys/rotate → { key: { id, key, hint }, … }* archive(id, owner) DELETE /api/v1/streams/:id (OpenRe refuses a live stream: 409)** A refusal (4xx) throws 502 bot.openre_refused with OpenRe's problem code and detail; no answer, a timeout-- * or a 5xx throws 503 bot.openre_unavailable. A 404 for a named stream (rotate, archive) answers null. The token is++ * or a 5xx throws 503 bot.openre_unavailable. A 404 for a named stream (get, update, rotate, archive) answers null. The token is* never logged, returned or put in an error. Unset BOT_OPENRE_URL or BOT_OPENRE_TOKEN: createOpenRe → null.*/const { BotError } = require('../util');@@@ -59,7 +61,20 @@ function createOpenRe(config, { fetchImpl = globalThis.fetch } = {}) {const out = await call('GET', `/api/v1/streams?external_ref=${encodeURIComponent(ref)}`, { owner });return (out && Array.isArray(out.streams) && out.streams[0]) || null;},++ async get(id, owner) {++ const out = await call('GET', stream(id), { owner, named: true });++ return (out && out.stream) || null;++ },create: (body, owner) => call('POST', '/api/v1/streams', { owner, body }),++ // Only the two streaming toggles this client owns are ever sent: a PATCH naming one leaves the other++ // as it is on OpenRe.++ async update(id, fields, owner) {++ const body = {};++ if (fields && fields.recording_mode !== undefined) body.recording_mode = fields.recording_mode;++ if (fields && fields.mirror_to_live !== undefined) body.mirror_to_live = fields.mirror_to_live;++ const out = await call('PATCH', stream(id), { owner, body, named: true });++ return (out && out.stream) || null;++ },rotate: (id, owner, { grace_seconds = 0, end_sessions = false } = {}) =>call('POST', `${stream(id)}/keys/rotate`, { owner, body: { grace_seconds, end_sessions }, named: true }),archive: (id, owner) => call('DELETE', stream(id), { owner, named: true }),@@@ -211,19 +211,50 @@ function renderWidget(w, { profile = {}, allowed_commands = [] } = {}) {return `<section class="widget widget-${esc(w.type)}" data-widget="${esc(w.type)}"${cmd}><h2>${esc(label)}</h2>${inner}</section>`;}--/** The owner's embed switch: plain markup, a checkbox posted as embed_public=on (the hidden field makes unchecked off). */++/** One owner switch: a checkbox posted as name=on, after a hidden name=off so an unchecked box still says off. */++const toggle = (name, on, title, note, disabled = false, was = false) => `${was ? `<input type="hidden" name="${esc(name)}_was" value="${on ? 'on' : 'off'}">` : ''}<input type="hidden" name="${esc(name)}" value="off">++<label class="toggle"><input type="checkbox" name="${esc(name)}" value="on"${on ? ' checked' : ''}${disabled ? ' disabled' : ''}><span><b>${esc(title)}</b><small>${esc(note)}</small></span></label>`;++++/** The owner's embed switch: plain markup, works without JavaScript. */function embedForm(robot) {-- return `<form class="embed-form" method="post" action="/robots/${esc(robot.id)}/embed" data-embed-form>--<input type="hidden" name="embed_public" value="off">--<label><input type="checkbox" name="embed_public" value="on"${robot.embed_public ? ' checked' : ''}> Let anyone see this robot's video and readouts on a page that embeds it (never the controls)</label>++ return `<section class="setting-card" aria-labelledby="embed-h"><h2 id="embed-h">Embedding</h2>++<form class="embed-form" 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.")}<button type="submit">Save</button>--</form>`;++</form></section>`;++}++++// Why the streaming switches cannot be used right now, in the owner's words (domain.streaming reasons, plus++// 'unavailable' when OpenRe did not answer while the page was drawn).++const STREAMING_WHY = {++ not_configured: 'Streaming needs OpenRe.Stream, which this server is not connected to.',++ not_paired: "Pair a device first: the robot's video stream is made when a device pairs.",++ stream_missing: "OpenRe no longer has this robot's stream. Pair the device again to make a new one.",++ unavailable: 'OpenRe did not answer just now, so these switches cannot be read. Reload in a moment.',++};++++/**++ * The owner's streaming and recording switches (plan T15 row S), both off until the owner turns them on. They++ * are the robot's OpenRe stream's own settings (domain.streaming): Bot keeps no copy, so the page shows what++ * OpenRe says. The panel's own video never depends on either.++ */++function streamingForm(robot, streaming) {++ const s = streaming || { available: false, reason: 'unavailable', media: { on: false }, live: { on: false } };++ const off = !s.available;++ const why = off ? `<p class="setting-note" role="status">${esc(STREAMING_WHY[s.reason] || STREAMING_WHY.unavailable)}${s.reason === 'not_paired' ? ` <a href="/pair/${esc(robot.id)}">Pair a device</a>` : ''}</p>` : '';++ return `<section class="setting-card" aria-labelledby="streaming-h"><h2 id="streaming-h">Streaming and recording</h2>++<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, OpenRe 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 OpenRe mirror the camera to your Live channel. It appears there once your channel plays OpenRe streams.', off, true)}++<button type="submit"${off ? ' disabled' : ''}>Save</button>++</form></section>`;}/** A link out of a frame: always a new tab, never with a handle back to the framing page. */const outLink = (href, text, cls = 'button') => `<a class="${cls}" href="${esc(href)}" target="_blank" rel="noopener">${esc(text)}</a>`;--function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs = 150, mode = 'page', signedIn = true }) {++function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs = 150, mode = 'page', signedIn = true, streaming = null }) {const embed = mode === 'embed';const latched = !!(robot.estop && robot.estop.latched);const widgets = (profile.widgets || []).map((w, i) => ({ w, i }))@@@ -245,7 +276,7 @@ ${embed && role === 'watcher' && !signedIn ? `<p class="embed-sign-in">${outLink<div class="widgets">${widgets.join('\n')}</div>--${role === 'owner' && !embed ? embedForm(robot) : ''}++${role === 'owner' && !embed ? `<div class="owner-settings">${embedForm(robot)}${streamingForm(robot, streaming)}</div>` : ''}</main>`;return page(robot.name, body, { scripts: ['/panel/panel.js'], bodyClass: embed ? 'embed-page' : 'panel-page' });}@@@ -62,7 +62,9 @@ function createWebRoutes(config, { domain = null, sim = null, limits = null, logres.setHeader('Cache-Control', 'no-store');try { await fn(req, res); } catch (e) {if (e.status === 401) return res.redirect(302, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);-- if (e.status === 403 || e.status === 404) return res.status(e.status).type('text/plain').send(`${e.detail || e.code}\n`);++ // A refusal the person can act on (a bad form value, a robot with no stream, OpenRe not answering) says so++ // with its own status instead of the generic error page.++ if ([403, 404, 409, 422, 502, 503].includes(e.status) && e.code) return res.status(e.status).type('text/plain').send(`${e.detail || e.code}\n`);return next(e);}return undefined;@@@ -134,6 +136,35 @@ function createWebRoutes(config, { domain = null, sim = null, limits = null, logres.redirect(303, `/panel/${robot.id}`);}));++ // The owner's streaming switches as a plain form (the same as POST /api/v1/robots/:id/streaming, once per++ // switch): media=on|off and live=on|off, each after its hidden "off" so the last value wins. A switch the++ // owner did not touch (its value equals the <name>_was the page was drawn with) is not sent, so a stale tab++ // never reverts a change made elsewhere; the domain is idempotent besides → 303 to the panel.++ r.post('/robots/:id/streaming', ...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 change streaming');++ const last = (v) => (Array.isArray(v) ? v[v.length - 1] : v);++ const changes = [];++ for (const to of ['media', 'live']) {++ const value = last(req.body && req.body[to]);++ if (value !== 'on' && value !== 'off') throw new BotError(422, 'bot.invalid_input', `${to} must be on or off`);++ const was = last(req.body && req.body[`${to}_was`]);++ if (was !== value) changes.push({ to, on: value === 'on' });++ }++ // One switch can be saved before OpenRe refuses or stops answering for the next: say which one was.++ const saved = [];++ for (const change of changes) {++ try { await domain.streaming.set(robot.id, me, change); saved.push(change.to); } catch (e) {++ if (!saved.length || !(e instanceof BotError)) throw e;++ throw new BotError(e.status, e.code, `${saved.join(' and ')} was saved, ${change.to} was not: ${e.detail || e.code}`);++ }++ }++ res.redirect(303, `/panel/${robot.id}`);++ }));++/** As the /control join decides: a member's role, or a place in the queue on a `queue` robot; else null. */async function memberRole(robot, me) {const role = await domain.members.roleOf(robot.id, me.subject);@@@ -142,8 +173,13 @@ function createWebRoutes(config, { domain = null, sim = null, limits = null, logasync function sendPanel(res, robot, profile, role, mode = 'page', signedIn = true) {await startSim(robot, profile);const { maxCommandMs } = domain.control.effectiveLimits(robot, profile);++ // Only the owner's own page shows the streaming switches; an OpenRe that does not answer leaves them++ // greyed out with a reason instead of failing the panel.++ const streaming = role === 'owner' && mode === 'page'++ ? await domain.streaming.get(robot.id).catch(() => ({ available: false, reason: 'unavailable', media: { on: false }, live: { on: false } }))++ : null;res.type('html').send(renderPanel({-- robot: presentForPage(robot), profile, role, mode, signedIn,++ robot: presentForPage(robot), profile, role, mode, signedIn, streaming,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))),@@@ -7,7 +7,8 @@* nodes, revokes), scoped to the calling service like Network's own routes* startEvents() POST /api/v1/events recording what Bot's outbox relays; GET /api/health* startOpenRe() OpenRe.Stream's stream routes Bot calls (server/api/v1.js there): GET /api/v1/streams?external_ref=,-- * POST /api/v1/streams, POST …/:id/keys/rotate, DELETE …/:id (409 while live), behind one bearer++ * GET /api/v1/streams/:id, POST /api/v1/streams, PATCH /api/v1/streams/:id (recording_mode /++ * mirror_to_live), POST …/:id/keys/rotate, DELETE …/:id (409 while live), behind one bearer* token whose capabilities OpenRe's guards check (setCaps(list) to narrow them; 403* capability.denied). streams, calls (every request, its token and X-OV-Subject), admits(key)* (OpenRe's resolveIngestKey: an active key or one in grace), failNext(status | 'hang')@@@ -150,6 +151,7 @@ async function startOpenRe({ token = `ovt_${crypto.randomBytes(16).toString('hexreturn { id: `key_${++n}`, key, hint: key.slice(-4), status: 'active', grace_until: null };};const view = (st) => ({ id: st.id, owner: { type: 'user', id: st.owner }, protocols: st.protocols, state: st.state, external_refs: st.refs,++ recording_mode: st.recording_mode, recording_visibility: st.recording_visibility, playback_visibility: st.playback_visibility, mirror_to_live: st.mirror_to_live,keys: st.keys.filter((k) => k.status !== 'revoked').map((k) => ({ id: k.id, hint: k.hint, status: k.status })) });const problem = (res, status, code, detail) => { res.writeHead(status, { 'Content-Type': 'application/problem+json' }); res.end(JSON.stringify({ type: 'about:blank', status, code, detail })); };function admits(key) {@@@ -183,13 +185,22 @@ async function startOpenRe({ token = `ovt_${crypto.randomBytes(16).toString('hexif (refs.some((r) => [...streams.values()].some((x) => x.state !== 'archived' && x.refs.some((y) => y.service === r.service && y.type === r.type && y.id === r.id)))) {return problem(res, 409, 'openre.ref_taken', 'already belongs to another stream definition');}-- const st = { id: `str_${++n}`, owner: subject, protocols: body.protocols, refs, state: 'active', live: false, keys: [newKey()] };++ const st = { id: `str_${++n}`, owner: subject, protocols: body.protocols, refs, state: 'active', live: false, keys: [newKey()],++ recording_mode: body.recording_mode || 'none', recording_visibility: body.recording_visibility || null,++ playback_visibility: body.playback_visibility || null, mirror_to_live: !!body.mirror_to_live };streams.set(st.id, st);const k = st.keys[0];return send(res, 201, { stream: view(st), key: { id: k.id, key: k.key, hint: k.hint, shown_once: true } });}const st = m && m[1] && mine(decodeURIComponent(m[1]));if (m && m[1] && !st) return problem(res, 404, 'openre.stream_not_found', 'no such stream definition');++ if (st && !m[2] && req.method === 'GET') return send(res, 200, { stream: view(st) });++ if (st && !m[2] && req.method === 'PATCH') {++ // Only the streaming toggles are writable here, as OpenRe's PATCH accepts: the rest stays put.++ if (body && body.recording_mode !== undefined) st.recording_mode = body.recording_mode;++ if (body && body.mirror_to_live !== undefined) st.mirror_to_live = !!body.mirror_to_live;++ return send(res, 200, { stream: view(st) });++ }if (st && m[2] && req.method === 'POST') {const grace = Math.max(0, Number(body && body.grace_seconds) || 0);const old = st.keys.filter((k) => k.status === 'active');nnew file mode 100644@@@ -0,0 +1,246 @@++'use strict';++// Streaming and recording toggles (plan T15 row S). The robot's OpenRe stream is the single source of truth:++// `media` is its recording_mode ('vod' records live sessions to OpenVibe.Media, 'none' does not) and `live`++// its mirror_to_live (the owner showing sessions on their OpenVibe.Live channel); both off by default. Bot++// stores no copy. Reading is a member read; changing is the owner's alone (`manage`, not `control`), and each++// real change is audited once (idempotent repeats are not).++const assert = require('assert');++const { boot, check, done } = require('./helpers/app');++++(async () => {++ const t = await boot();++ const alex = t.network.newUser('alex');++ const operator = t.network.newUser('operator');++ const viewer = t.network.newUser('viewer');++ const stranger = t.network.newUser('stranger');++++ const robotRow = (id) => t.db.maybe('SELECT * FROM robots WHERE id = $1', [id]);++ async function pairedRobot(owner = alex) {++ const { robot, pairing } = await t.robot(owner);++ const paired = await t.call('POST', '/api/v1/pair', { token: null, body: { robot: robot.id, code: pairing.code } });++ assert.strictEqual(paired.status, 201, paired.text);++ return { robot, paired: paired.json, streamId: (await robotRow(robot.id)).openre_stream_id };++ }++ const getStreaming = (robot, user = alex) => t.call('GET', `/api/v1/robots/${robot.id}/streaming`, { user });++ const postStreaming = (robot, body, user = alex) => t.call('POST', `/api/v1/robots/${robot.id}/streaming`, { user, body });++ const patches = () => t.openre.calls.filter((c) => c.method === 'PATCH');++ const auditOf = async (robot, kind) => (await t.domain.audit.list(robot.id)).filter((r) => r.kind === kind);++++ await check('a newly paired robot streams off by default, read from OpenRe', async () => {++ const { robot, streamId } = await pairedRobot();++ assert.ok(streamId, 'pairing created the OpenRe stream');++ const st = t.openre.streams.get(streamId);++ assert.strictEqual(st.recording_mode, 'none', 'created with recording off');++ assert.strictEqual(st.mirror_to_live, false, 'created without mirroring');++ const r = await getStreaming(robot);++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.json.available, true);++ assert.deepStrictEqual(r.json.media, { on: false });++ assert.strictEqual(r.json.live.on, false);++ assert.strictEqual(r.json.live.effective, 'when your Live channel plays OpenRe streams');++ assert.strictEqual(r.json.stream_id, streamId);++ });++++ await check('the owner turns media on and off: OpenRe is PATCHed recording_mode vod/none', async () => {++ const { robot, streamId } = await pairedRobot();++ const on = await postStreaming(robot, { to: 'media', on: true });++ assert.strictEqual(on.status, 200, on.text);++ assert.strictEqual(on.json.media.on, true);++ const call = patches().at(-1);++ assert.strictEqual(call.path, `/api/v1/streams/${streamId}`);++ assert.deepStrictEqual(call.body, { recording_mode: 'vod' });++ assert.strictEqual(call.subject, alex.subject, 'OpenRe is asked for the robot\'s owner');++ assert.strictEqual(t.openre.streams.get(streamId).recording_mode, 'vod');++ assert.strictEqual((await getStreaming(robot)).json.media.on, true);++ assert.strictEqual((await t.call('GET', `/api/v1/robots/${robot.id}/audit`, { user: alex })).json.audit.find((a) => a.kind === 'streaming.media').value.on, true);++++ const off = await postStreaming(robot, { to: 'media', on: false });++ assert.strictEqual(off.status, 200, off.text);++ assert.strictEqual(off.json.media.on, false);++ assert.deepStrictEqual(patches().at(-1).body, { recording_mode: 'none' });++ assert.strictEqual(t.openre.streams.get(streamId).recording_mode, 'none');++ assert.strictEqual((await getStreaming(robot)).json.media.on, false);++ });++++ await check('the owner turns live on and off: OpenRe is PATCHed mirror_to_live', async () => {++ const { robot, streamId } = await pairedRobot();++ const on = await postStreaming(robot, { to: 'live', on: true });++ assert.strictEqual(on.status, 200, on.text);++ assert.strictEqual(on.json.live.on, true);++ assert.deepStrictEqual(patches().at(-1).body, { mirror_to_live: true });++ assert.strictEqual(t.openre.streams.get(streamId).mirror_to_live, true);++++ const off = await postStreaming(robot, { to: 'live', on: false });++ assert.strictEqual(off.status, 200, off.text);++ assert.strictEqual(off.json.live.on, false);++ assert.deepStrictEqual(patches().at(-1).body, { mirror_to_live: false });++ assert.strictEqual(t.openre.streams.get(streamId).mirror_to_live, false);++ });++++ await check('only the owner toggles: an operator and a viewer get 403; a member may read', async () => {++ const { robot, streamId } = await pairedRobot();++ await t.call('POST', `/api/v1/robots/${robot.id}/operators`, { user: alex, body: { subject: operator.subject, role: 'operator' } });++ await t.call('POST', `/api/v1/robots/${robot.id}/operators`, { user: alex, body: { subject: viewer.subject, role: 'viewer' } });++ const before = patches().length;++ assert.strictEqual((await postStreaming(robot, { to: 'media', on: true }, operator)).status, 403);++ assert.strictEqual((await postStreaming(robot, { to: 'live', on: true }, viewer)).status, 403);++ assert.strictEqual((await postStreaming(robot, { to: 'media', on: true }, stranger)).status, 403);++ assert.strictEqual(patches().length, before, 'a refused toggle never reaches OpenRe');++ assert.strictEqual(t.openre.streams.get(streamId).recording_mode, 'none');++ assert.strictEqual((await getStreaming(robot, operator)).status, 200);++ assert.strictEqual((await getStreaming(robot, viewer)).status, 200);++ assert.strictEqual((await getStreaming(robot, stranger)).status, 403);++ });++++ await check('a bad toggle is 422 bot.invalid_streaming and nothing is PATCHed', async () => {++ const { robot } = await pairedRobot();++ const before = patches().length;++ for (const body of [{ to: 'openre', on: true }, { to: 'media', on: 'yes' }, { to: 'media' }, { to: 'media', on: 1 }, {}]) {++ const r = await postStreaming(robot, body);++ assert.strictEqual(r.status, 422, `${JSON.stringify(body)}: ${r.text}`);++ assert.strictEqual(r.json.code, 'bot.invalid_streaming');++ }++ assert.strictEqual(patches().length, before, 'no PATCH was sent');++ });++++ await check('an unpaired robot has nothing to read and the write refuses bot.not_paired', async () => {++ const { robot } = await t.robot(alex);++ const got = await getStreaming(robot);++ assert.strictEqual(got.status, 200, got.text);++ assert.strictEqual(got.json.available, false);++ assert.strictEqual(got.json.reason, 'not_paired');++ assert.strictEqual(got.json.stream_id, null);++ assert.strictEqual(got.json.media.on, false);++ const post = await postStreaming(robot, { to: 'media', on: true });++ assert.strictEqual(post.status, 409, post.text);++ assert.strictEqual(post.json.code, 'bot.not_paired');++ });++++ await check('an OpenRe 404 reads as stream_missing and the write refuses bot.not_paired', async () => {++ const { robot, streamId } = await pairedRobot();++ t.openre.streams.delete(streamId);++ const got = await getStreaming(robot);++ assert.strictEqual(got.status, 200, got.text);++ assert.strictEqual(got.json.available, false);++ assert.strictEqual(got.json.reason, 'stream_missing');++ assert.strictEqual(got.json.stream_id, streamId, 'the stored id is still reported');++ const post = await postStreaming(robot, { to: 'live', on: true });++ assert.strictEqual(post.status, 409, post.text);++ assert.strictEqual(post.json.code, 'bot.not_paired');++ });++++ await check('setting the value it already has answers 200 and writes no second audit row', async () => {++ const { robot } = await pairedRobot();++ assert.deepStrictEqual(await auditOf(robot, 'streaming.media'), []);++ const first = await postStreaming(robot, { to: 'media', on: true });++ assert.strictEqual(first.status, 200, first.text);++ assert.strictEqual(first.json.media.on, true);++ const rows = await auditOf(robot, 'streaming.media');++ assert.strictEqual(rows.length, 1, 'one audit row for the change');++ assert.strictEqual(rows[0].operator_subject, alex.subject);++ assert.strictEqual(rows[0].operator_kind, 'user');++ assert.strictEqual(rows[0].role, 'owner');++ assert.deepStrictEqual(rows[0].value, { on: true });++ assert.strictEqual(rows[0].result, 'ack');++++ const before = patches().length;++ const again = await postStreaming(robot, { to: 'media', on: true });++ assert.strictEqual(again.status, 200, again.text);++ assert.strictEqual(again.json.media.on, true);++ assert.strictEqual(patches().length, before, 'no second PATCH');++ assert.strictEqual((await auditOf(robot, 'streaming.media')).length, 1, 'no second audit row');++++ const off = await postStreaming(robot, { to: 'media', on: false });++ assert.strictEqual(off.status, 200, off.text);++ assert.strictEqual((await auditOf(robot, 'streaming.media')).length, 2, 'a real change audits again');++ assert.deepStrictEqual((await auditOf(robot, 'streaming.live')), [], 'live was never touched');++ });++++++ // ── BOT_OPENRE_URL / BOT_OPENRE_TOKEN unset: no stream to read or set ─────────────────────────++++ // The owner's panel carries the same switches as a plain form (works without JavaScript).++ const cookie = (user) => ({ Cookie: `ov_token=${t.network.signUser(user)}` });++ const page = (path, user) => fetch(t.base + path, { redirect: 'manual', headers: cookie(user) });++ const form = (path, user, body) => fetch(t.base + path, {++ method: 'POST', redirect: 'manual', body, headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...cookie(user) },++ });++++ await check('the owner panel shows both switches off and the form sets them on OpenRe', async () => {++ const { robot, streamId } = await pairedRobot();++ 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.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);++ assert.strictEqual(saved.headers.get('location'), `/panel/${robot.id}`);++ assert.strictEqual(t.openre.streams.get(streamId).recording_mode, 'vod');++ assert.strictEqual(t.openre.streams.get(streamId).mirror_to_live, false);++ html = await (await page(`/panel/${robot.id}`, alex)).text();++ assert.match(html, /name="media" value="on" checked/);++ assert.doesNotMatch(html, /name="live" value="on" checked/);++ assert.strictEqual((await auditOf(robot, 'streaming.live')).length, 0, 'an unchanged switch writes nothing');++ const bad = await form(`/robots/${robot.id}/streaming`, alex, 'media=maybe&live=off');++ assert.strictEqual(bad.status, 422);++ assert.match(html, /name="media_was" value="on"/, 'the page carries what each switch was drawn with');++ });++++ await check('a stale tab does not revert a switch changed elsewhere', async () => {++ const erin = t.network.newUser('erin');++ const { robot, streamId } = await pairedRobot(erin);++ // Drawn with both off; meanwhile live was turned on elsewhere; the stale form only turns media on.++ t.openre.streams.get(streamId).mirror_to_live = true;++ const r = await form(`/robots/${robot.id}/streaming`, erin, 'media_was=off&media=off&media=on&live_was=off&live=off');++ assert.strictEqual(r.status, 303);++ assert.strictEqual(t.openre.streams.get(streamId).recording_mode, 'vod');++ assert.strictEqual(t.openre.streams.get(streamId).mirror_to_live, true, 'the untouched live switch is not reverted');++ });++++ await check('only the owner sees and posts the streaming form', async () => {++ const { robot } = await pairedRobot();++ await t.call('POST', `/api/v1/robots/${robot.id}/operators`, { user: alex, body: { subject: operator.subject, role: 'operator' } });++ const html = await (await page(`/panel/${robot.id}`, operator)).text();++ assert.doesNotMatch(html, /data-streaming-form/, 'an operator sees no streaming switches');++ assert.strictEqual((await form(`/robots/${robot.id}/streaming`, operator, 'media=on&live=on')).status, 403);++ assert.strictEqual((await form(`/robots/${robot.id}/streaming`, stranger, 'media=on&live=on')).status, 403);++ });++++ await check('an unpaired robot greys the switches out and says to pair a device', async () => {++ const { robot } = await t.robot(alex, { name: 'No device yet' });++ const html = await (await page(`/panel/${robot.id}`, alex)).text();++ assert.match(html, /Pair a device first/);++ assert.match(html, new RegExp(`href="/pair/${robot.id}"`));++ assert.match(html, /name="media" value="on" disabled/);++ });++++ await check('an OpenRe that does not answer leaves the panel up with the switches greyed out', async () => {++ const dana = t.network.newUser('dana'); // a fresh owner: alex has used up the robot.manage minute by now++ const { robot } = await pairedRobot(dana);++ const real = t.domain.streaming.get;++ t.domain.streaming.get = async () => { throw Object.assign(new Error('down'), { status: 503 }); };++ try {++ const r = await page(`/panel/${robot.id}`, dana);++ assert.strictEqual(r.status, 200);++ assert.match(await r.text(), /OpenRe did not answer just now/);++ } finally { t.domain.streaming.get = real; }++ });++ await t.close();++ const u = await boot({ openre: false });++ const sam = u.network.newUser('sam');++ await check('without OpenRe configured streaming reads not_configured and the write is 409', async () => {++ const { robot, pairing } = await u.robot(sam);++ const paired = await u.call('POST', '/api/v1/pair', { token: null, body: { robot: robot.id, code: pairing.code } });++ assert.strictEqual(paired.status, 201, paired.text);++ const got = await u.call('GET', `/api/v1/robots/${robot.id}/streaming`, { user: sam });++ assert.strictEqual(got.status, 200, got.text);++ assert.strictEqual(got.json.available, false);++ assert.strictEqual(got.json.reason, 'not_configured');++ const post = await u.call('POST', `/api/v1/robots/${robot.id}/streaming`, { user: sam, body: { to: 'live', on: true } });++ assert.strictEqual(post.status, 409, post.text);++ assert.strictEqual(post.json.code, 'bot.openre_not_configured');++ });++ await u.close();++ done();++})();
@@ -14,6 +14,7 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato
@@ -14,6 +14,7 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato
- The `/device` and `/control` WebSockets, the command gate and the latched e-stop.
- The `/device` and `/control` WebSockets, the command gate and the latched e-stop.
- The signed-in pages (`server/web/routes.js`): `/robots` to list and add robots, `/pair/:id` for a pairing code and the installer command, and `/panel/:id`, the panel rendered from the profile. A `sim.rover` robot is driven by an in-process simulator (`server/sim`), so the panel works before any hardware exists. It gets no jobs, and it is not counted as a device or reported online in the outbox. The panel (`public/panel.js`, no build step) drives with a touch joystick, the keyboard or a gamepad, shows a latency meter and one camera tile per camera the profile lists; the camera stays a placeholder until OpenRe.Stream can play a WHIP-published stream back to a browser.
- The signed-in pages (`server/web/routes.js`): `/robots` to list and add robots, `/pair/:id` for a pairing code and the installer command, and `/panel/:id`, the panel rendered from the profile. A `sim.rover` robot is driven by an in-process simulator (`server/sim`), so the panel works before any hardware exists. It gets no jobs, and it is not counted as a device or reported online in the outbox. The panel (`public/panel.js`, no build step) drives with a touch joystick, the keyboard or a gamepad, shows a latency meter and one camera tile per camera the profile lists; the camera stays a placeholder until OpenRe.Stream can play a WHIP-published stream back to a browser.
- The command audit (`command_audit`, kept 30 days) and the `bot.*` outbox events.
- The command audit (`command_audit`, kept 30 days) and the `bot.*` outbox events.
+- The robot's streaming and recording toggles, off by default: `media` records live sessions to OpenVibe.Media and `live` shows them on the owner's OpenVibe.Live channel. They live only on the robot's OpenRe stream (`recording_mode`, `mirror_to_live`); Bot stores no copy and the owner, not an operator, flips them (`GET`/`POST /api/v1/robots/:id/streaming`).
- Dispatching `platform.job@1` jobs to a paired Node over `/device` (`server/jobs/index.js` over `server/jobs/dispatch.js`; the internal `bot.job.dispatch` API is `POST /api/v1/jobs`, `POST /jobs/:id/cancel`, `GET /jobs/:id`) and metering them per wall-clock second (`run_jobs`, `run_usage_outbox`; docs/protocol.md §1.2).
- Dispatching `platform.job@1` jobs to a paired Node over `/device` (`server/jobs/index.js` over `server/jobs/dispatch.js`; the internal `bot.job.dispatch` API is `POST /api/v1/jobs`, `POST /jobs/:id/cancel`, `GET /jobs/:id`) and metering them per wall-clock second (`run_jobs`, `run_usage_outbox`; docs/protocol.md §1.2).
## Does not own
## Does not own
@@ -41,8 +42,8 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato
@@ -41,8 +42,8 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato
| capability | guards |
| capability | guards |
|---|---|
|---|---|
-| `bot.robot.read` | `GET /robots`, `GET /robots/:id`, operators, devices and the audit, for a service acting for an owner |
-| `bot.robot.manage` | create, patch and delete a robot, pairing codes, operators |
+| `bot.robot.read` | `GET /robots`, `GET /robots/:id`, operators, devices, the audit and the streaming toggles, for a service acting for an owner |
+| `bot.robot.manage` | create, patch and delete a robot, pairing codes, operators, and the streaming toggles (`POST /robots/:id/streaming`) |
| `bot.robot.control` | the e-stop set and clear (clear is owner-only), the control gate and `POST /robots/:id/commands` (so a bound channel's chat can forward a command over HTTP instead of a hardware socket) |
| `bot.robot.control` | the e-stop set and clear (clear is owner-only), the control gate and `POST /robots/:id/commands` (so a bound channel's chat can forward a command over HTTP instead of a hardware socket) |
| `bot.device.connect` | rotate and revoke a device credential |
| `bot.device.connect` | rotate and revoke a device credential |
| `bot.job.dispatch` | the internal Run → Bot jobs API (`POST /jobs`, `POST /jobs/:id/cancel`, `GET /jobs/:id`); services only |
| `bot.job.dispatch` | the internal Run → Bot jobs API (`POST /jobs`, `POST /jobs/:id/cancel`, `GET /jobs/:id`); services only |
@@ -75,7 +76,7 @@ The embeddable panel is `GET /panel/:id/embed` (framed only by `BOT_EMBED_ORIGIN
@@ -75,7 +76,7 @@ The embeddable panel is `GET /panel/:id/embed` (framed only by `BOT_EMBED_ORIGIN
Set `BOT_WHIP_BASE` in `/etc/openvibe/bot.env` to the WHIP ingest base each device publishes to (`whip_url = <base>/<publish key>`, sent once with the pairing; OpenRe's is `https://ingest.openre.stream/whip`); leave it unset to pair devices without video.
Set `BOT_WHIP_BASE` in `/etc/openvibe/bot.env` to the WHIP ingest base each device publishes to (`whip_url = <base>/<publish key>`, sent once with the pairing; OpenRe's is `https://ingest.openre.stream/whip`); leave it unset to pair devices without video.
-The publish key is always an ingest key OpenRe.Stream issued for the robot's stream (OpenRe's WHIP ingest admits no other): set `BOT_OPENRE_URL` and `BOT_OPENRE_TOKEN` (a Network service token holding `openre.stream.read`, `openre.stream.write` and `openre.key.rotate`, or `openre.stream.*` with `openre.key.rotate`; `BOT_OPENRE_TIMEOUT_MS`, default 8000, bounds each call). Pairing creates the robot's OpenRe stream (or rotates the one it has), a credential rotation rotates its key, revoking a device or removing a robot revokes the key and ends the live session. Bot stores the stream id and the key's hint, never the key. With either variable unset, devices still pair but get no publish key and the answer says `"video": "not_configured"`; Bot mints no key of its own.
+The publish key is always an ingest key OpenRe.Stream issued for the robot's stream (OpenRe's WHIP ingest admits no other): set `BOT_OPENRE_URL` and `BOT_OPENRE_TOKEN` (a Network service token holding `openre.stream.read`, `openre.stream.write` and `openre.key.rotate`, or `openre.stream.*` with `openre.key.rotate`; `BOT_OPENRE_TIMEOUT_MS`, default 8000, bounds each call). The same token covers the streaming toggles: `GET /api/v1/robots/:id/streaming` reads the stream with `openre.stream.read`, `POST` PATCHes `recording_mode`/`mirror_to_live` with `openre.stream.write`. Pairing creates the robot's OpenRe stream (or rotates the one it has), a credential rotation rotates its key, revoking a device or removing a robot revokes the key and ends the live session. Bot stores the stream id and the key's hint, never the key. With either variable unset, devices still pair but get no publish key and the answer says `"video": "not_configured"`; Bot mints no key of its own.
`/install` is proxied to the app, which answers a 302 to OpenVibe.Node's installer script (`BOT_INSTALLER_SOURCE_URL`, by default the `install.sh` asset on OpenVibe.Node's [latest release](https://github.com/OpenVibers/OpenVibe.Node/releases/latest/download/) — a released, checksummed installer that never drifts with Node's `main`, whose CI keeps `dist/install.sh` byte-identical to `install/install.sh`; https on an allow-listed GitHub or openvibe.bot host, checked at boot, and no query parameter changes the target). The pairing's installer command adds `--driver adeept|adeept-mecanum|cozmo` for those profiles and nothing for the others (the dry-run `none`).
`/install` is proxied to the app, which answers a 302 to OpenVibe.Node's installer script (`BOT_INSTALLER_SOURCE_URL`, by default the `install.sh` asset on OpenVibe.Node's [latest release](https://github.com/OpenVibers/OpenVibe.Node/releases/latest/download/) — a released, checksummed installer that never drifts with Node's `main`, whose CI keeps `dist/install.sh` byte-identical to `install/install.sh`; https on an allow-listed GitHub or openvibe.bot host, checked at boot, and no query parameter changes the target). The pairing's installer command adds `--driver adeept|adeept-mecanum|cozmo` for those profiles and nothing for the others (the dry-run `none`).
@@ -41,7 +41,8 @@
@@ -41,7 +41,8 @@
"panel input: a touch joystick, the keyboard and a gamepad (Gamepad API), each held control re-sent while held and zeroed at once on release; a latency meter (round-trip median and telemetry age); one camera tile per camera the profile lists (a placeholder until OpenRe can play a WHIP stream back); a live online indicator on /pair/:id read from /control",
"panel input: a touch joystick, the keyboard and a gamepad (Gamepad API), each held control re-sent while held and zeroed at once on release; a latency meter (round-trip median and telemetry age); one camera tile per camera the profile lists (a placeholder until OpenRe can play a WHIP stream back); a live online indicator on /pair/:id read from /control",
"platform.job@1 jobs dispatched to a paired Node over /device (platform.job-frame@1: resent unacked on reconnect, only to an advertised runtime class) and metered per second into run_usage_outbox for billing.usage.record",
"platform.job@1 jobs dispatched to a paired Node over /device (platform.job-frame@1: resent unacked on reconnect, only to an advertised runtime class) and metered per second into run_usage_outbox for billing.usage.record",
"embed opt-in: robots.embed_public (migration 0005), set only by the owner with POST /robots/:id/embed, web-only (not in /api/v1); BOT_EMBED_ORIGINS is the validated frame-ancestors allow-list for the embeddable panel (default OpenVibe.Live)",
"embed opt-in: robots.embed_public (migration 0005), set only by the owner with POST /robots/:id/embed, web-only (not in /api/v1); BOT_EMBED_ORIGINS is the validated frame-ancestors allow-list for the embeddable panel (default OpenVibe.Live)",
- "embeddable panel: GET /panel/:id/embed framed only by BOT_EMBED_ORIGINS (no cookie, never a sign-in redirect; a member keeps their role, anyone else watches an embed_public robot) and the anonymous read-only /watch socket carrying the public state (online, e-stop, latency, battery, the profile's readouts), capped per address and per robot"
+ "embeddable panel: GET /panel/:id/embed framed only by BOT_EMBED_ORIGINS (no cookie, never a sign-in redirect; a member keeps their role, anyone else watches an embed_public robot) and the anonymous read-only /watch socket carrying the public state (online, e-stop, latency, battery, the profile's readouts), capped per address and per robot",
+ "streaming and recording toggles per robot, off by default and owner-only: media records live sessions to OpenVibe.Media (the OpenRe stream's recording_mode vod/none) and live shows them on the owner's OpenVibe.Live channel (mirror_to_live), read at GET /api/v1/robots/:id/streaming and set at POST /api/v1/robots/:id/streaming; Bot stores no copy, each real change audits once"
],
],
"updated": "2026-10-04"
"updated": "2026-10-04"
}
}
@@ -121,7 +121,10 @@ pairing with `BOT_OPENRE_URL`/`BOT_OPENRE_TOKEN` set and found again by that ref
@@ -121,7 +121,10 @@ pairing with `BOT_OPENRE_URL`/`BOT_OPENRE_TOKEN` set and found again by that ref
A pairing or `POST /devices/bind` rotates the stream's key with no grace (the robot's previous device stops publishing), a
A pairing or `POST /devices/bind` rotates the stream's key with no grace (the robot's previous device stops publishing), a
credential rotation with the credential's grace, a revocation with no grace and the live session ended;
credential rotation with the credential's grace, a revocation with no grace and the live session ended;
removing the robot does the same and archives the stream when nothing is live. Bot stores the stream id and
removing the robot does the same and archives the stream when nothing is live. Bot stores the stream id and
-the key's hint, never the key. OpenRe refusing is `502 bot.openre_refused` (its problem code in `detail`),
+the key's hint, never the key. The same token carries the streaming toggles: `GET /robots/:id/streaming`
+reads the stream (`openre.stream.read`) and `POST` PATCHes `recording_mode`/`mirror_to_live`
+(`openre.stream.write`); Bot keeps neither, the OpenRe stream is the only copy.
+OpenRe refusing is `502 bot.openre_refused` (its problem code in `detail`),
not answering `503 bot.openre_unavailable`; a pairing that fails so leaves the code unused and no device.
not answering `503 bot.openre_unavailable`; a pairing that fails so leaves the code unused and no device.
With `BOT_OPENRE_URL` or `BOT_OPENRE_TOKEN` unset the answers carry `"video": "not_configured"` and neither
With `BOT_OPENRE_URL` or `BOT_OPENRE_TOKEN` unset the answers carry `"video": "not_configured"` and neither
`publish_key` nor `whip_url`.
`publish_key` nor `whip_url`.
@@ -429,7 +432,7 @@ the subject it acts for). Without a token, every route but `GET /profiles`, `GET
@@ -429,7 +432,7 @@ the subject it acts for). Without a token, every route but `GET /profiles`, `GET
and `POST /devices/bind` answers `401 bot.sign_in`: `GET|POST /robots`, `GET|PATCH|DELETE /robots/:id`,
and `POST /devices/bind` answers `401 bot.sign_in`: `GET|POST /robots`, `GET|PATCH|DELETE /robots/:id`,
`POST /robots/:id/pairing-code`, `GET|POST /robots/:id/operators`, `DELETE /robots/:id/operators/:subject`,
`POST /robots/:id/pairing-code`, `GET|POST /robots/:id/operators`, `DELETE /robots/:id/operators/:subject`,
`GET /robots/:id/devices`, `POST /devices/:id/rotate|revoke`, `POST /robots/:id/estop`,
`GET /robots/:id/devices`, `POST /devices/:id/rotate|revoke`, `POST /robots/:id/estop`,
-`POST /robots/:id/estop/clear`, `POST /robots/:id/commands` and `GET /robots/:id/audit`. A node token gets `403 bot.forbidden` on the same
+`POST /robots/:id/estop/clear`, `POST /robots/:id/commands`, `GET /robots/:id/audit` and `GET|POST /robots/:id/streaming`. A node token gets `403 bot.forbidden` on the same
routes. Both come before any lookup, so a real robot or device id answers exactly as a made-up one. The three
routes. Both come before any lookup, so a real robot or device id answers exactly as a made-up one. The three
jobs routes are service-only: the same `401 bot.sign_in` without a token, `403 bot.forbidden` for a person or a
jobs routes are service-only: the same `401 bot.sign_in` without a token, `403 bot.forbidden` for a person or a
node token, and a service token is judged on `bot.job.dispatch` alone.
node token, and a service token is judged on `bot.job.dispatch` alone.
@@ -454,6 +457,8 @@ node token, and a service token is judged on `bot.job.dispatch` alone.
@@ -454,6 +457,8 @@ node token, and a service token is judged on `bot.job.dispatch` alone.
| `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 |
| `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) |
+| `GET /robots/:id/streaming` | a member, or `bot.robot.read` | `{ available, media{on}, live{on,effective}, stream_id }` (plus `reason` when unavailable), read from the robot's OpenRe stream; `available:false` is `not_configured` (OpenRe unset), `not_paired` (no stream yet) or `stream_missing` (OpenRe answered 404) |
+| `POST /robots/:id/streaming` | **the owner only**, or `bot.robot.manage` | body `{ to: "media"\|"live", on: bool }` → the new state (as `GET`). `media` sets the stream's `recording_mode` `vod`/`none` (record to OpenVibe.Media); `live` sets `mirror_to_live` (show on the owner's OpenVibe.Live channel). One audit row per real change (`streaming.media`/`streaming.live`); an idempotent repeat answers the state and writes none. `422 bot.invalid_streaming`; `409 bot.not_paired` / `bot.openre_not_configured` |
| `POST /jobs`, `POST /jobs/:id/cancel`, `GET /jobs/:id` | **a service only**, with `bot.job.dispatch` (Run → Bot; a person or a node token is `403 bot.forbidden`) | `201 { job, sent }` / `{ job, sent }` / `{ job, stdout }`; `422 bot.invalid_input` without `project_id`, `409 bot.class_unadvertised` when the device does not advertise the job's class, `404 bot.job_not_found` |
| `POST /jobs`, `POST /jobs/:id/cancel`, `GET /jobs/:id` | **a service only**, with `bot.job.dispatch` (Run → Bot; a person or a node token is `403 bot.forbidden`) | `201 { job, sent }` / `{ job, sent }` / `{ job, stdout }`; `422 bot.invalid_input` without `project_id`, `409 bot.class_unadvertised` when the device does not advertise the job's class, `404 bot.job_not_found` |
| `POST /pair` | the one-time code is the credential | `201 { device_id, credential, publish_key, whip_url, robot_id, profile }`; with `BOT_PAIRING_AUTHORITY=network` always `410 bot.pairing_moved` (the `detail` names the Network URL) |
| `POST /pair` | the one-time code is the credential | `201 { device_id, credential, publish_key, whip_url, robot_id, profile }`; with `BOT_PAIRING_AUTHORITY=network` always `410 bot.pairing_moved` (the `detail` names the Network URL) |
@@ -469,6 +474,8 @@ and the signed-in pages `GET|POST /robots`, `GET /pair/:id`, `GET /panel/:id` an
@@ -469,6 +474,8 @@ and the signed-in pages `GET|POST /robots`, `GET /pair/:id`, `GET /panel/:id` an
Every command — allowed or refused — is written to `command_audit` (robot, device, the operator
Every command — allowed or refused — is written to `command_audit` (robot, device, the operator
principal and its kind, role, kind, the clamped value summary, result `ack|nack|refused|expired`,
principal and its kind, role, kind, the clamped value summary, result `ack|nack|refused|expired`,
-reason, latency, time), kept 30 days. The outbox publishes `bot.robot.online`, `bot.robot.offline`,
+reason, latency, time), kept 30 days. Changing a robot's streaming toggles writes one row per real change
+(`kind` `streaming.media` or `streaming.live`, `value` `{ on }`, role `owner`, result `ack`); an
+idempotent repeat writes none. The outbox publishes `bot.robot.online`, `bot.robot.offline`,
`bot.estop.set`, `bot.estop.cleared` and `bot.command.refused` — small payloads of ids, kinds and
`bot.estop.set`, `bot.estop.cleared` and `bot.command.refused` — small payloads of ids, kinds and
results, never a credential, a pairing code, a publish key or a WHIP URL.
results, never a credential, a pairing code, a publish key or a WHIP URL.
@@ -203,3 +203,20 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf
@@ -203,3 +203,20 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf
.installer { margin: 0 0 0.75rem; overflow-x: auto; padding: 0.85rem; background: var(--bg); border: 1px solid var(--line); border-radius: 10px; font-size: 0.85rem; }
.installer { margin: 0 0 0.75rem; overflow-x: auto; padding: 0.85rem; background: var(--bg); border: 1px solid var(--line); border-radius: 10px; font-size: 0.85rem; }
.copy[data-copied="true"] { border-color: var(--ok); color: var(--ok); }
.copy[data-copied="true"] { border-color: var(--ok); color: var(--ok); }
.code { font-family: ui-monospace, monospace; font-size: 1.75rem; letter-spacing: 0.15em; }
.code { font-family: ui-monospace, monospace; font-size: 1.75rem; letter-spacing: 0.15em; }
+
+/* The owner's settings under the panel: embedding and the streaming switches, one card each. */
+.owner-settings { display: grid; gap: 0.75rem; margin-top: 0.75rem; grid-template-columns: minmax(0, 1fr); }
+@media (min-width: 900px) { .owner-settings { grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: start; } }
+.setting-card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: 0.9rem; min-width: 0; }
+.setting-card form { display: grid; gap: 0.6rem; }
+.setting-card button[type="submit"] { justify-self: start; }
+.setting-note { color: var(--muted); font-size: 0.88rem; margin: -0.35rem 0 0.7rem; }
+.setting-note a { color: var(--accent); }
+.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); }
+.toggle:has(input:disabled) { cursor: not-allowed; }
+.toggle input { width: 1.25rem; height: 1.25rem; min-width: 0; min-height: 0; margin: 0.12rem 0 0; accent-color: var(--accent); }
+.toggle b { display: block; font-weight: 600; }
+.toggle small { display: block; margin-top: 0.15rem; color: var(--muted); font-size: 0.85rem; line-height: 1.4; }
+.toggle:has(input:disabled) b, .toggle:has(input:disabled) small { opacity: 0.55; }
@@ -24,6 +24,8 @@
@@ -24,6 +24,8 @@
* | 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 |
* | 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 |
+ * | POST /robots/:id/streaming | bot.robot.manage | the owner |
* | POST /jobs, /jobs/:id/cancel, GET /jobs/:id | bot.job.dispatch | — (services only) |
* | POST /jobs, /jobs/:id/cancel, GET /jobs/:id | bot.job.dispatch | — (services only) |
* | POST /pair | — (one-time code) | the agent (code is the credential) |
* | POST /pair | — (one-time code) | the agent (code is the credential) |
*
*
@@ -221,6 +223,19 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config }) {
@@ -221,6 +223,19 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config }) {
res.json({ device: domain.present.device(revoked) });
res.json({ device: domain.present.device(revoked) });
}));
}));
+ // ── Streaming (the owner's OpenRe toggles, read from OpenRe; Bot stores no copy) ───────────────
+ // Reading is a member read, like GET /robots/:id. Changing is the owner's alone (`manage`, not
+ // `control`): whether a robot's camera is recorded or shown on the owner's channel is their call.
+ r.get('/robots/:id/streaming', wrap(async (req, res) => {
+ await member(req, req.params.id);
+ res.json(await domain.streaming.get(req.params.id));
+ }));
+ r.post('/robots/:id/streaming', manage, wrap(async (req, res) => {
+ const robot = await owner(req, req.params.id);
+ const subject = req.principal.kind === 'user' ? me(req) : userSubject(req.headers['x-ov-subject'] || req.body?.owner, 'subject');
+ res.json(await domain.streaming.set(req.params.id, { subject, kind: req.principal.kind }, { to: req.body?.to, on: req.body?.on }));
+ }));
+
// ── E-stop ────────────────────────────────────────────────────────────────────────────────────
// ── E-stop ────────────────────────────────────────────────────────────────────────────────────
r.post('/robots/:id/estop', control, wrap(async (req, res) => {
r.post('/robots/:id/estop', control, wrap(async (req, res) => {
const { robot } = await member(req, req.params.id, ['owner', 'operator']);
const { robot } = await member(req, req.params.id, ['owner', 'operator']);
@@ -332,6 +332,60 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -332,6 +332,60 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
const clean = base ? String(base).replace(/\/+$/, '') : '';
const clean = base ? String(base).replace(/\/+$/, '') : '';
return clean && publishKey ? `${clean}/${encodeURIComponent(publishKey)}` : null;
return clean && publishKey ? `${clean}/${encodeURIComponent(publishKey)}` : null;
}
}
+ // ── Streaming toggles (the owner's OpenRe stream is the single source of truth) ────────────────
+ // Bot stores no copy: `media` is the stream's recording_mode ('vod' on, 'none' off) and `live` its
+ // mirror_to_live. `live` is the owner's consent, but OpenRe mirrors a session into their Live channel
+ // only once Live plays OpenRe streams for that channel, so the answer always carries `effective`.
+ const LIVE_EFFECTIVE = 'when your Live channel plays OpenRe streams';
+ const streamingState = (robot, stream) => ({
+ available: true,
+ media: { on: stream.recording_mode === 'vod' },
+ live: { on: !!stream.mirror_to_live, effective: LIVE_EFFECTIVE },
+ stream_id: robot.openre_stream_id,
+ });
+ /** The offline shape: nothing to read, with why — OpenRe unset, no stream yet, or OpenRe lost it. */
+ const streamingUnavailable = (robot, reason) => ({
+ available: false, reason, stream_id: robot.openre_stream_id || null,
+ media: { on: false }, live: { on: false, effective: LIVE_EFFECTIVE },
+ });
+ /**
+ * The robot's streaming toggles, read from its OpenRe stream. `available: false` with reason
+ * 'not_configured' (OpenRe unset), 'not_paired' (the robot has no stream yet) or 'stream_missing'
+ * (OpenRe answers 404).
+ */
+ async function streaming(robotId) {
+ const robot = await getRobot(robotId);
+ if (!robot) fail(404, 'bot.robot_not_found', 'no such robot');
+ if (!openre) return streamingUnavailable(robot, 'not_configured');
+ if (!robot.openre_stream_id) return streamingUnavailable(robot, 'not_paired');
+ const stream = await openre.get(robot.openre_stream_id, robot.owner_subject);
+ if (!stream) return streamingUnavailable(robot, 'stream_missing');
+ return streamingState(robot, stream);
+ }
+ /**
+ * Turn `media` (recording_mode 'vod'/'none') or `live` (mirror_to_live) on or off on the robot's OpenRe
+ * stream, and audit the change once (kind streaming.media/streaming.live, value { on }). Idempotent:
+ * setting the value it already has answers the state and writes no audit row.
+ */
+ async function setStreaming(robotId, actor, { to, on } = {}) {
+ const robot = await getRobot(robotId);
+ if (!robot) fail(404, 'bot.robot_not_found', 'no such robot');
+ if (!['media', 'live'].includes(to) || typeof on !== 'boolean') fail(422, 'bot.invalid_streaming', "to must be 'media' or 'live' and on must be true or false");
+ if (!openre) fail(409, 'bot.openre_not_configured', 'OpenRe is not configured here');
+ if (!robot.openre_stream_id) fail(409, 'bot.not_paired', 'the robot has no OpenRe stream yet; pair a device first');
+ const current = await openre.get(robot.openre_stream_id, robot.owner_subject);
+ if (!current) fail(409, 'bot.not_paired', 'OpenRe no longer has the robot\'s stream; pair a device again');
+ const already = to === 'media' ? current.recording_mode === 'vod' : !!current.mirror_to_live;
+ if (already === on) return streamingState(robot, current);
+ const fields = to === 'media' ? { recording_mode: on ? 'vod' : 'none' } : { mirror_to_live: on };
+ const updated = await openre.update(robot.openre_stream_id, fields, robot.owner_subject);
+ if (!updated) fail(409, 'bot.not_paired', 'OpenRe no longer has the robot\'s stream; pair a device again');
+ await auditCommand({
+ robotId, subject: actor && actor.subject, operatorKind: actor && actor.kind, role: 'owner',
+ kind: `streaming.${to}`, value: { on }, result: 'ack',
+ });
+ return streamingState(robot, updated);
+ }
async function prunePairingCodes() {
async function prunePairingCodes() {
await db.query('DELETE FROM pairing_codes WHERE expires_at < $1', [iso(now() - 24 * 3600 * 1000)]);
await db.query('DELETE FROM pairing_codes WHERE expires_at < $1', [iso(now() - 24 * 3600 * 1000)]);
}
}
@@ -793,6 +847,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -793,6 +847,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
members: { roleOf, add: addOperator, remove: removeOperator, list: listOperators },
members: { roleOf, add: addOperator, remove: removeOperator, list: listOperators },
pairing: { create: createPairingCode, redeem, prune: prunePairingCodes, installerCommand, driverForProfile, whipUrl },
pairing: { create: createPairingCode, redeem, prune: prunePairingCodes, installerCommand, driverForProfile, whipUrl },
devices: { byCredential, bindNode, issuePublishKey, revokeVideo, updateDeclared, revokeNode, get: getDevice, listForRobot: listDevicesForRobot, rotate: rotateDevice, revoke: revokeDevice, touchSeen, setOnline },
devices: { byCredential, bindNode, issuePublishKey, revokeVideo, updateDeclared, revokeNode, get: getDevice, listForRobot: listDevicesForRobot, rotate: rotateDevice, revoke: revokeDevice, touchSeen, setOnline },
+ streaming: { get: streaming, set: setStreaming },
estop: { set: setEstop, clear: clearEstop },
estop: { set: setEstop, clear: clearEstop },
queue: { join: joinQueue, state: queueState, currentTurn, consume: consumeTurn, sweep: sweepQueues },
queue: { join: joinQueue, state: queueState, currentTurn, consume: consumeTurn, sweep: sweepQueues },
audit: { record: auditCommand, list: listAudit, listPage: listAuditPage, prune: pruneAudit },
audit: { record: auditCommand, list: listAudit, listPage: listAuditPage, prune: pruneAudit },
@@ -11,13 +11,15 @@
@@ -11,13 +11,15 @@
* (X-OV-Subject), so OpenRe limits them to that owner's streams.
* (X-OV-Subject), so OpenRe limits them to that owner's streams.
*
*
* find(ref, owner) GET /api/v1/streams?external_ref=… → the stream, or null
* find(ref, owner) GET /api/v1/streams?external_ref=… → the stream, or null
+ * get(id, owner) GET /api/v1/streams/:id → the stream, or null on 404
* create(body, owner) POST /api/v1/streams → { stream, key: { id, key, hint } }
* create(body, owner) POST /api/v1/streams → { stream, key: { id, key, hint } }
+ * update(id, fields, owner) PATCH /api/v1/streams/:id → the updated stream, or null on 404
* rotate(id, owner, { grace_seconds, end_sessions })
* rotate(id, owner, { grace_seconds, end_sessions })
* POST /api/v1/streams/:id/keys/rotate → { key: { id, key, hint }, … }
* POST /api/v1/streams/:id/keys/rotate → { key: { id, key, hint }, … }
* archive(id, owner) DELETE /api/v1/streams/:id (OpenRe refuses a live stream: 409)
* archive(id, owner) DELETE /api/v1/streams/:id (OpenRe refuses a live stream: 409)
*
*
* A refusal (4xx) throws 502 bot.openre_refused with OpenRe's problem code and detail; no answer, a timeout
* A refusal (4xx) throws 502 bot.openre_refused with OpenRe's problem code and detail; no answer, a timeout
- * or a 5xx throws 503 bot.openre_unavailable. A 404 for a named stream (rotate, archive) answers null. The token is
+ * or a 5xx throws 503 bot.openre_unavailable. A 404 for a named stream (get, update, rotate, archive) answers null. The token is
* never logged, returned or put in an error. Unset BOT_OPENRE_URL or BOT_OPENRE_TOKEN: createOpenRe → null.
* never logged, returned or put in an error. Unset BOT_OPENRE_URL or BOT_OPENRE_TOKEN: createOpenRe → null.
*/
*/
const { BotError } = require('../util');
const { BotError } = require('../util');
@@ -59,7 +61,20 @@ function createOpenRe(config, { fetchImpl = globalThis.fetch } = {}) {
@@ -59,7 +61,20 @@ function createOpenRe(config, { fetchImpl = globalThis.fetch } = {}) {
const out = await call('GET', `/api/v1/streams?external_ref=${encodeURIComponent(ref)}`, { owner });
const out = await call('GET', `/api/v1/streams?external_ref=${encodeURIComponent(ref)}`, { owner });
return (out && Array.isArray(out.streams) && out.streams[0]) || null;
return (out && Array.isArray(out.streams) && out.streams[0]) || null;
},
},
+ async get(id, owner) {
+ const out = await call('GET', stream(id), { owner, named: true });
+ return (out && out.stream) || null;
+ },
create: (body, owner) => call('POST', '/api/v1/streams', { owner, body }),
create: (body, owner) => call('POST', '/api/v1/streams', { owner, body }),
+ // Only the two streaming toggles this client owns are ever sent: a PATCH naming one leaves the other
+ // as it is on OpenRe.
+ async update(id, fields, owner) {
+ const body = {};
+ if (fields && fields.recording_mode !== undefined) body.recording_mode = fields.recording_mode;
+ if (fields && fields.mirror_to_live !== undefined) body.mirror_to_live = fields.mirror_to_live;
+ const out = await call('PATCH', stream(id), { owner, body, named: true });
+ return (out && out.stream) || null;
+ },
rotate: (id, owner, { grace_seconds = 0, end_sessions = false } = {}) =>
rotate: (id, owner, { grace_seconds = 0, end_sessions = false } = {}) =>
call('POST', `${stream(id)}/keys/rotate`, { owner, body: { grace_seconds, end_sessions }, named: true }),
call('POST', `${stream(id)}/keys/rotate`, { owner, body: { grace_seconds, end_sessions }, named: true }),
archive: (id, owner) => call('DELETE', stream(id), { owner, named: true }),
archive: (id, owner) => call('DELETE', stream(id), { owner, named: true }),
@@ -211,19 +211,50 @@ function renderWidget(w, { profile = {}, allowed_commands = [] } = {}) {
@@ -211,19 +211,50 @@ function renderWidget(w, { profile = {}, allowed_commands = [] } = {}) {
return `<section class="widget widget-${esc(w.type)}" data-widget="${esc(w.type)}"${cmd}><h2>${esc(label)}</h2>${inner}</section>`;
return `<section class="widget widget-${esc(w.type)}" data-widget="${esc(w.type)}"${cmd}><h2>${esc(label)}</h2>${inner}</section>`;
}
}
-/** The owner's embed switch: plain markup, a checkbox posted as embed_public=on (the hidden field makes unchecked off). */
+/** One owner switch: a checkbox posted as name=on, after a hidden name=off so an unchecked box still says off. */
+const toggle = (name, on, title, note, disabled = false, was = false) => `${was ? `<input type="hidden" name="${esc(name)}_was" value="${on ? 'on' : 'off'}">` : ''}<input type="hidden" name="${esc(name)}" value="off">
+<label class="toggle"><input type="checkbox" name="${esc(name)}" value="on"${on ? ' checked' : ''}${disabled ? ' disabled' : ''}><span><b>${esc(title)}</b><small>${esc(note)}</small></span></label>`;
+
+/** The owner's embed switch: plain markup, works without JavaScript. */
function embedForm(robot) {
function embedForm(robot) {
- return `<form class="embed-form" method="post" action="/robots/${esc(robot.id)}/embed" data-embed-form>
-<input type="hidden" name="embed_public" value="off">
-<label><input type="checkbox" name="embed_public" value="on"${robot.embed_public ? ' checked' : ''}> Let anyone see this robot's video and readouts on a page that embeds it (never the controls)</label>
+ return `<section class="setting-card" aria-labelledby="embed-h"><h2 id="embed-h">Embedding</h2>
+<form class="embed-form" 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.")}
<button type="submit">Save</button>
<button type="submit">Save</button>
-</form>`;
+</form></section>`;
+}
+
+// Why the streaming switches cannot be used right now, in the owner's words (domain.streaming reasons, plus
+// 'unavailable' when OpenRe did not answer while the page was drawn).
+const STREAMING_WHY = {
+ not_configured: 'Streaming needs OpenRe.Stream, which this server is not connected to.',
+ not_paired: "Pair a device first: the robot's video stream is made when a device pairs.",
+ stream_missing: "OpenRe no longer has this robot's stream. Pair the device again to make a new one.",
+ unavailable: 'OpenRe did not answer just now, so these switches cannot be read. Reload in a moment.',
+};
+
+/**
+ * The owner's streaming and recording switches (plan T15 row S), both off until the owner turns them on. They
+ * are the robot's OpenRe stream's own settings (domain.streaming): Bot keeps no copy, so the page shows what
+ * OpenRe says. The panel's own video never depends on either.
+ */
+function streamingForm(robot, streaming) {
+ const s = streaming || { available: false, reason: 'unavailable', media: { on: false }, live: { on: false } };
+ const off = !s.available;
+ const why = off ? `<p class="setting-note" role="status">${esc(STREAMING_WHY[s.reason] || STREAMING_WHY.unavailable)}${s.reason === 'not_paired' ? ` <a href="/pair/${esc(robot.id)}">Pair a device</a>` : ''}</p>` : '';
+ return `<section class="setting-card" aria-labelledby="streaming-h"><h2 id="streaming-h">Streaming and recording</h2>
+<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, OpenRe 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 OpenRe mirror the camera to your Live channel. It appears there once your channel plays OpenRe streams.', off, true)}
+<button type="submit"${off ? ' disabled' : ''}>Save</button>
+</form></section>`;
}
}
/** A link out of a frame: always a new tab, never with a handle back to the framing page. */
/** A link out of a frame: always a new tab, never with a handle back to the framing page. */
const outLink = (href, text, cls = 'button') => `<a class="${cls}" href="${esc(href)}" target="_blank" rel="noopener">${esc(text)}</a>`;
const outLink = (href, text, cls = 'button') => `<a class="${cls}" href="${esc(href)}" target="_blank" rel="noopener">${esc(text)}</a>`;
-function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs = 150, mode = 'page', signedIn = true }) {
+function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs = 150, mode = 'page', signedIn = true, streaming = 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 }))
@@ -245,7 +276,7 @@ ${embed && role === 'watcher' && !signedIn ? `<p class="embed-sign-in">${outLink
@@ -245,7 +276,7 @@ ${embed && role === 'watcher' && !signedIn ? `<p class="embed-sign-in">${outLink
<div class="widgets">
<div class="widgets">
${widgets.join('\n')}
${widgets.join('\n')}
</div>
</div>
-${role === 'owner' && !embed ? embedForm(robot) : ''}
+${role === 'owner' && !embed ? `<div class="owner-settings">${embedForm(robot)}${streamingForm(robot, streaming)}</div>` : ''}
</main>`;
</main>`;
return page(robot.name, body, { scripts: ['/panel/panel.js'], bodyClass: embed ? 'embed-page' : 'panel-page' });
return page(robot.name, body, { scripts: ['/panel/panel.js'], bodyClass: embed ? 'embed-page' : 'panel-page' });
}
}
@@ -62,7 +62,9 @@ function createWebRoutes(config, { domain = null, sim = null, limits = null, log
@@ -62,7 +62,9 @@ function createWebRoutes(config, { domain = null, sim = null, limits = null, log
res.setHeader('Cache-Control', 'no-store');
res.setHeader('Cache-Control', 'no-store');
try { await fn(req, res); } catch (e) {
try { await fn(req, res); } catch (e) {
if (e.status === 401) return res.redirect(302, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);
if (e.status === 401) return res.redirect(302, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);
- if (e.status === 403 || e.status === 404) return res.status(e.status).type('text/plain').send(`${e.detail || e.code}\n`);
+ // A refusal the person can act on (a bad form value, a robot with no stream, OpenRe not answering) says so
+ // with its own status instead of the generic error page.
+ if ([403, 404, 409, 422, 502, 503].includes(e.status) && e.code) return res.status(e.status).type('text/plain').send(`${e.detail || e.code}\n`);
return next(e);
return next(e);
}
}
return undefined;
return undefined;
@@ -134,6 +136,35 @@ function createWebRoutes(config, { domain = null, sim = null, limits = null, log
@@ -134,6 +136,35 @@ function createWebRoutes(config, { domain = null, sim = null, limits = null, log
res.redirect(303, `/panel/${robot.id}`);
res.redirect(303, `/panel/${robot.id}`);
}));
}));
+ // The owner's streaming switches as a plain form (the same as POST /api/v1/robots/:id/streaming, once per
+ // switch): media=on|off and live=on|off, each after its hidden "off" so the last value wins. A switch the
+ // owner did not touch (its value equals the <name>_was the page was drawn with) is not sent, so a stale tab
+ // never reverts a change made elsewhere; the domain is idempotent besides → 303 to the panel.
+ r.post('/robots/:id/streaming', ...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 change streaming');
+ const last = (v) => (Array.isArray(v) ? v[v.length - 1] : v);
+ const changes = [];
+ for (const to of ['media', 'live']) {
+ const value = last(req.body && req.body[to]);
+ if (value !== 'on' && value !== 'off') throw new BotError(422, 'bot.invalid_input', `${to} must be on or off`);
+ const was = last(req.body && req.body[`${to}_was`]);
+ if (was !== value) changes.push({ to, on: value === 'on' });
+ }
+ // One switch can be saved before OpenRe refuses or stops answering for the next: say which one was.
+ const saved = [];
+ for (const change of changes) {
+ try { await domain.streaming.set(robot.id, me, change); saved.push(change.to); } catch (e) {
+ if (!saved.length || !(e instanceof BotError)) throw e;
+ throw new BotError(e.status, e.code, `${saved.join(' and ')} was saved, ${change.to} was not: ${e.detail || e.code}`);
+ }
+ }
+ res.redirect(303, `/panel/${robot.id}`);
+ }));
+
/** As the /control join decides: a member's role, or a place in the queue on a `queue` robot; else null. */
/** As the /control join decides: a member's role, or a place in the queue on a `queue` robot; else null. */
async function memberRole(robot, me) {
async function memberRole(robot, me) {
const role = await domain.members.roleOf(robot.id, me.subject);
const role = await domain.members.roleOf(robot.id, me.subject);
@@ -142,8 +173,13 @@ function createWebRoutes(config, { domain = null, sim = null, limits = null, log
@@ -142,8 +173,13 @@ function createWebRoutes(config, { domain = null, sim = null, limits = null, log
async function sendPanel(res, robot, profile, role, mode = 'page', signedIn = true) {
async function sendPanel(res, robot, profile, role, mode = 'page', signedIn = true) {
await startSim(robot, profile);
await startSim(robot, profile);
const { maxCommandMs } = domain.control.effectiveLimits(robot, profile);
const { maxCommandMs } = domain.control.effectiveLimits(robot, profile);
+ // Only the owner's own page shows the streaming switches; an OpenRe that does not answer leaves them
+ // greyed out with a reason instead of failing the panel.
+ const streaming = role === 'owner' && mode === 'page'
+ ? await domain.streaming.get(robot.id).catch(() => ({ available: false, reason: 'unavailable', media: { on: false }, live: { on: false } }))
+ : null;
res.type('html').send(renderPanel({
res.type('html').send(renderPanel({
- robot: presentForPage(robot), profile, role, mode, signedIn,
+ robot: presentForPage(robot), profile, role, mode, signedIn, streaming,
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))),
@@ -7,7 +7,8 @@
@@ -7,7 +7,8 @@
* nodes, revokes), scoped to the calling service like Network's own routes
* nodes, revokes), scoped to the calling service like Network's own routes
* startEvents() POST /api/v1/events recording what Bot's outbox relays; GET /api/health
* startEvents() POST /api/v1/events recording what Bot's outbox relays; GET /api/health
* startOpenRe() OpenRe.Stream's stream routes Bot calls (server/api/v1.js there): GET /api/v1/streams?external_ref=,
* startOpenRe() OpenRe.Stream's stream routes Bot calls (server/api/v1.js there): GET /api/v1/streams?external_ref=,
- * POST /api/v1/streams, POST …/:id/keys/rotate, DELETE …/:id (409 while live), behind one bearer
+ * GET /api/v1/streams/:id, POST /api/v1/streams, PATCH /api/v1/streams/:id (recording_mode /
+ * mirror_to_live), POST …/:id/keys/rotate, DELETE …/:id (409 while live), behind one bearer
* token whose capabilities OpenRe's guards check (setCaps(list) to narrow them; 403
* token whose capabilities OpenRe's guards check (setCaps(list) to narrow them; 403
* capability.denied). streams, calls (every request, its token and X-OV-Subject), admits(key)
* capability.denied). streams, calls (every request, its token and X-OV-Subject), admits(key)
* (OpenRe's resolveIngestKey: an active key or one in grace), failNext(status | 'hang')
* (OpenRe's resolveIngestKey: an active key or one in grace), failNext(status | 'hang')
@@ -150,6 +151,7 @@ async function startOpenRe({ token = `ovt_${crypto.randomBytes(16).toString('hex
@@ -150,6 +151,7 @@ async function startOpenRe({ token = `ovt_${crypto.randomBytes(16).toString('hex
return { id: `key_${++n}`, key, hint: key.slice(-4), status: 'active', grace_until: null };
return { id: `key_${++n}`, key, hint: key.slice(-4), status: 'active', grace_until: null };
};
};
const view = (st) => ({ id: st.id, owner: { type: 'user', id: st.owner }, protocols: st.protocols, state: st.state, external_refs: st.refs,
const view = (st) => ({ id: st.id, owner: { type: 'user', id: st.owner }, protocols: st.protocols, state: st.state, external_refs: st.refs,
+ recording_mode: st.recording_mode, recording_visibility: st.recording_visibility, playback_visibility: st.playback_visibility, mirror_to_live: st.mirror_to_live,
keys: st.keys.filter((k) => k.status !== 'revoked').map((k) => ({ id: k.id, hint: k.hint, status: k.status })) });
keys: st.keys.filter((k) => k.status !== 'revoked').map((k) => ({ id: k.id, hint: k.hint, status: k.status })) });
const problem = (res, status, code, detail) => { res.writeHead(status, { 'Content-Type': 'application/problem+json' }); res.end(JSON.stringify({ type: 'about:blank', status, code, detail })); };
const problem = (res, status, code, detail) => { res.writeHead(status, { 'Content-Type': 'application/problem+json' }); res.end(JSON.stringify({ type: 'about:blank', status, code, detail })); };
function admits(key) {
function admits(key) {
@@ -183,13 +185,22 @@ async function startOpenRe({ token = `ovt_${crypto.randomBytes(16).toString('hex
@@ -183,13 +185,22 @@ async function startOpenRe({ token = `ovt_${crypto.randomBytes(16).toString('hex
if (refs.some((r) => [...streams.values()].some((x) => x.state !== 'archived' && x.refs.some((y) => y.service === r.service && y.type === r.type && y.id === r.id)))) {
if (refs.some((r) => [...streams.values()].some((x) => x.state !== 'archived' && x.refs.some((y) => y.service === r.service && y.type === r.type && y.id === r.id)))) {
return problem(res, 409, 'openre.ref_taken', 'already belongs to another stream definition');
return problem(res, 409, 'openre.ref_taken', 'already belongs to another stream definition');
}
}
- const st = { id: `str_${++n}`, owner: subject, protocols: body.protocols, refs, state: 'active', live: false, keys: [newKey()] };
+ const st = { id: `str_${++n}`, owner: subject, protocols: body.protocols, refs, state: 'active', live: false, keys: [newKey()],
+ recording_mode: body.recording_mode || 'none', recording_visibility: body.recording_visibility || null,
+ playback_visibility: body.playback_visibility || null, mirror_to_live: !!body.mirror_to_live };
streams.set(st.id, st);
streams.set(st.id, st);
const k = st.keys[0];
const k = st.keys[0];
return send(res, 201, { stream: view(st), key: { id: k.id, key: k.key, hint: k.hint, shown_once: true } });
return send(res, 201, { stream: view(st), key: { id: k.id, key: k.key, hint: k.hint, shown_once: true } });
}
}
const st = m && m[1] && mine(decodeURIComponent(m[1]));
const st = m && m[1] && mine(decodeURIComponent(m[1]));
if (m && m[1] && !st) return problem(res, 404, 'openre.stream_not_found', 'no such stream definition');
if (m && m[1] && !st) return problem(res, 404, 'openre.stream_not_found', 'no such stream definition');
+ if (st && !m[2] && req.method === 'GET') return send(res, 200, { stream: view(st) });
+ if (st && !m[2] && req.method === 'PATCH') {
+ // Only the streaming toggles are writable here, as OpenRe's PATCH accepts: the rest stays put.
+ if (body && body.recording_mode !== undefined) st.recording_mode = body.recording_mode;
+ if (body && body.mirror_to_live !== undefined) st.mirror_to_live = !!body.mirror_to_live;
+ return send(res, 200, { stream: view(st) });
+ }
if (st && m[2] && req.method === 'POST') {
if (st && m[2] && req.method === 'POST') {
const grace = Math.max(0, Number(body && body.grace_seconds) || 0);
const grace = Math.max(0, Number(body && body.grace_seconds) || 0);
const old = st.keys.filter((k) => k.status === 'active');
const old = st.keys.filter((k) => k.status === 'active');
new file mode 100644
new file mode 100644
@@ -0,0 +1,246 @@
@@ -0,0 +1,246 @@
+'use strict';
+// Streaming and recording toggles (plan T15 row S). The robot's OpenRe stream is the single source of truth:
+// `media` is its recording_mode ('vod' records live sessions to OpenVibe.Media, 'none' does not) and `live`
+// its mirror_to_live (the owner showing sessions on their OpenVibe.Live channel); both off by default. Bot
+// stores no copy. Reading is a member read; changing is the owner's alone (`manage`, not `control`), and each
+// real change is audited once (idempotent repeats are not).
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/app');
+
+(async () => {
+ const t = await boot();
+ const alex = t.network.newUser('alex');
+ const operator = t.network.newUser('operator');
+ const viewer = t.network.newUser('viewer');
+ const stranger = t.network.newUser('stranger');
+
+ const robotRow = (id) => t.db.maybe('SELECT * FROM robots WHERE id = $1', [id]);
+ async function pairedRobot(owner = alex) {
+ const { robot, pairing } = await t.robot(owner);
+ const paired = await t.call('POST', '/api/v1/pair', { token: null, body: { robot: robot.id, code: pairing.code } });
+ assert.strictEqual(paired.status, 201, paired.text);
+ return { robot, paired: paired.json, streamId: (await robotRow(robot.id)).openre_stream_id };
+ }
+ const getStreaming = (robot, user = alex) => t.call('GET', `/api/v1/robots/${robot.id}/streaming`, { user });
+ const postStreaming = (robot, body, user = alex) => t.call('POST', `/api/v1/robots/${robot.id}/streaming`, { user, body });
+ const patches = () => t.openre.calls.filter((c) => c.method === 'PATCH');
+ const auditOf = async (robot, kind) => (await t.domain.audit.list(robot.id)).filter((r) => r.kind === kind);
+
+ await check('a newly paired robot streams off by default, read from OpenRe', async () => {
+ const { robot, streamId } = await pairedRobot();
+ assert.ok(streamId, 'pairing created the OpenRe stream');
+ const st = t.openre.streams.get(streamId);
+ assert.strictEqual(st.recording_mode, 'none', 'created with recording off');
+ assert.strictEqual(st.mirror_to_live, false, 'created without mirroring');
+ const r = await getStreaming(robot);
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.json.available, true);
+ assert.deepStrictEqual(r.json.media, { on: false });
+ assert.strictEqual(r.json.live.on, false);
+ assert.strictEqual(r.json.live.effective, 'when your Live channel plays OpenRe streams');
+ assert.strictEqual(r.json.stream_id, streamId);
+ });
+
+ await check('the owner turns media on and off: OpenRe is PATCHed recording_mode vod/none', async () => {
+ const { robot, streamId } = await pairedRobot();
+ const on = await postStreaming(robot, { to: 'media', on: true });
+ assert.strictEqual(on.status, 200, on.text);
+ assert.strictEqual(on.json.media.on, true);
+ const call = patches().at(-1);
+ assert.strictEqual(call.path, `/api/v1/streams/${streamId}`);
+ assert.deepStrictEqual(call.body, { recording_mode: 'vod' });
+ assert.strictEqual(call.subject, alex.subject, 'OpenRe is asked for the robot\'s owner');
+ assert.strictEqual(t.openre.streams.get(streamId).recording_mode, 'vod');
+ assert.strictEqual((await getStreaming(robot)).json.media.on, true);
+ assert.strictEqual((await t.call('GET', `/api/v1/robots/${robot.id}/audit`, { user: alex })).json.audit.find((a) => a.kind === 'streaming.media').value.on, true);
+
+ const off = await postStreaming(robot, { to: 'media', on: false });
+ assert.strictEqual(off.status, 200, off.text);
+ assert.strictEqual(off.json.media.on, false);
+ assert.deepStrictEqual(patches().at(-1).body, { recording_mode: 'none' });
+ assert.strictEqual(t.openre.streams.get(streamId).recording_mode, 'none');
+ assert.strictEqual((await getStreaming(robot)).json.media.on, false);
+ });
+
+ await check('the owner turns live on and off: OpenRe is PATCHed mirror_to_live', async () => {
+ const { robot, streamId } = await pairedRobot();
+ const on = await postStreaming(robot, { to: 'live', on: true });
+ assert.strictEqual(on.status, 200, on.text);
+ assert.strictEqual(on.json.live.on, true);
+ assert.deepStrictEqual(patches().at(-1).body, { mirror_to_live: true });
+ assert.strictEqual(t.openre.streams.get(streamId).mirror_to_live, true);
+
+ const off = await postStreaming(robot, { to: 'live', on: false });
+ assert.strictEqual(off.status, 200, off.text);
+ assert.strictEqual(off.json.live.on, false);
+ assert.deepStrictEqual(patches().at(-1).body, { mirror_to_live: false });
+ assert.strictEqual(t.openre.streams.get(streamId).mirror_to_live, false);
+ });
+
+ await check('only the owner toggles: an operator and a viewer get 403; a member may read', async () => {
+ const { robot, streamId } = await pairedRobot();
+ await t.call('POST', `/api/v1/robots/${robot.id}/operators`, { user: alex, body: { subject: operator.subject, role: 'operator' } });
+ await t.call('POST', `/api/v1/robots/${robot.id}/operators`, { user: alex, body: { subject: viewer.subject, role: 'viewer' } });
+ const before = patches().length;
+ assert.strictEqual((await postStreaming(robot, { to: 'media', on: true }, operator)).status, 403);
+ assert.strictEqual((await postStreaming(robot, { to: 'live', on: true }, viewer)).status, 403);
+ assert.strictEqual((await postStreaming(robot, { to: 'media', on: true }, stranger)).status, 403);
+ assert.strictEqual(patches().length, before, 'a refused toggle never reaches OpenRe');
+ assert.strictEqual(t.openre.streams.get(streamId).recording_mode, 'none');
+ assert.strictEqual((await getStreaming(robot, operator)).status, 200);
+ assert.strictEqual((await getStreaming(robot, viewer)).status, 200);
+ assert.strictEqual((await getStreaming(robot, stranger)).status, 403);
+ });
+
+ await check('a bad toggle is 422 bot.invalid_streaming and nothing is PATCHed', async () => {
+ const { robot } = await pairedRobot();
+ const before = patches().length;
+ for (const body of [{ to: 'openre', on: true }, { to: 'media', on: 'yes' }, { to: 'media' }, { to: 'media', on: 1 }, {}]) {
+ const r = await postStreaming(robot, body);
+ assert.strictEqual(r.status, 422, `${JSON.stringify(body)}: ${r.text}`);
+ assert.strictEqual(r.json.code, 'bot.invalid_streaming');
+ }
+ assert.strictEqual(patches().length, before, 'no PATCH was sent');
+ });
+
+ await check('an unpaired robot has nothing to read and the write refuses bot.not_paired', async () => {
+ const { robot } = await t.robot(alex);
+ const got = await getStreaming(robot);
+ assert.strictEqual(got.status, 200, got.text);
+ assert.strictEqual(got.json.available, false);
+ assert.strictEqual(got.json.reason, 'not_paired');
+ assert.strictEqual(got.json.stream_id, null);
+ assert.strictEqual(got.json.media.on, false);
+ const post = await postStreaming(robot, { to: 'media', on: true });
+ assert.strictEqual(post.status, 409, post.text);
+ assert.strictEqual(post.json.code, 'bot.not_paired');
+ });
+
+ await check('an OpenRe 404 reads as stream_missing and the write refuses bot.not_paired', async () => {
+ const { robot, streamId } = await pairedRobot();
+ t.openre.streams.delete(streamId);
+ const got = await getStreaming(robot);
+ assert.strictEqual(got.status, 200, got.text);
+ assert.strictEqual(got.json.available, false);
+ assert.strictEqual(got.json.reason, 'stream_missing');
+ assert.strictEqual(got.json.stream_id, streamId, 'the stored id is still reported');
+ const post = await postStreaming(robot, { to: 'live', on: true });
+ assert.strictEqual(post.status, 409, post.text);
+ assert.strictEqual(post.json.code, 'bot.not_paired');
+ });
+
+ await check('setting the value it already has answers 200 and writes no second audit row', async () => {
+ const { robot } = await pairedRobot();
+ assert.deepStrictEqual(await auditOf(robot, 'streaming.media'), []);
+ const first = await postStreaming(robot, { to: 'media', on: true });
+ assert.strictEqual(first.status, 200, first.text);
+ assert.strictEqual(first.json.media.on, true);
+ const rows = await auditOf(robot, 'streaming.media');
+ assert.strictEqual(rows.length, 1, 'one audit row for the change');
+ assert.strictEqual(rows[0].operator_subject, alex.subject);
+ assert.strictEqual(rows[0].operator_kind, 'user');
+ assert.strictEqual(rows[0].role, 'owner');
+ assert.deepStrictEqual(rows[0].value, { on: true });
+ assert.strictEqual(rows[0].result, 'ack');
+
+ const before = patches().length;
+ const again = await postStreaming(robot, { to: 'media', on: true });
+ assert.strictEqual(again.status, 200, again.text);
+ assert.strictEqual(again.json.media.on, true);
+ assert.strictEqual(patches().length, before, 'no second PATCH');
+ assert.strictEqual((await auditOf(robot, 'streaming.media')).length, 1, 'no second audit row');
+
+ const off = await postStreaming(robot, { to: 'media', on: false });
+ assert.strictEqual(off.status, 200, off.text);
+ assert.strictEqual((await auditOf(robot, 'streaming.media')).length, 2, 'a real change audits again');
+ assert.deepStrictEqual((await auditOf(robot, 'streaming.live')), [], 'live was never touched');
+ });
+
+
+ // ── BOT_OPENRE_URL / BOT_OPENRE_TOKEN unset: no stream to read or set ─────────────────────────
+
+ // The owner's panel carries the same switches as a plain form (works without JavaScript).
+ const cookie = (user) => ({ Cookie: `ov_token=${t.network.signUser(user)}` });
+ const page = (path, user) => fetch(t.base + path, { redirect: 'manual', headers: cookie(user) });
+ const form = (path, user, body) => fetch(t.base + path, {
+ method: 'POST', redirect: 'manual', body, headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...cookie(user) },
+ });
+
+ await check('the owner panel shows both switches off and the form sets them on OpenRe', async () => {
+ const { robot, streamId } = await pairedRobot();
+ 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.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);
+ assert.strictEqual(saved.headers.get('location'), `/panel/${robot.id}`);
+ assert.strictEqual(t.openre.streams.get(streamId).recording_mode, 'vod');
+ assert.strictEqual(t.openre.streams.get(streamId).mirror_to_live, false);
+ html = await (await page(`/panel/${robot.id}`, alex)).text();
+ assert.match(html, /name="media" value="on" checked/);
+ assert.doesNotMatch(html, /name="live" value="on" checked/);
+ assert.strictEqual((await auditOf(robot, 'streaming.live')).length, 0, 'an unchanged switch writes nothing');
+ const bad = await form(`/robots/${robot.id}/streaming`, alex, 'media=maybe&live=off');
+ assert.strictEqual(bad.status, 422);
+ assert.match(html, /name="media_was" value="on"/, 'the page carries what each switch was drawn with');
+ });
+
+ await check('a stale tab does not revert a switch changed elsewhere', async () => {
+ const erin = t.network.newUser('erin');
+ const { robot, streamId } = await pairedRobot(erin);
+ // Drawn with both off; meanwhile live was turned on elsewhere; the stale form only turns media on.
+ t.openre.streams.get(streamId).mirror_to_live = true;
+ const r = await form(`/robots/${robot.id}/streaming`, erin, 'media_was=off&media=off&media=on&live_was=off&live=off');
+ assert.strictEqual(r.status, 303);
+ assert.strictEqual(t.openre.streams.get(streamId).recording_mode, 'vod');
+ assert.strictEqual(t.openre.streams.get(streamId).mirror_to_live, true, 'the untouched live switch is not reverted');
+ });
+
+ await check('only the owner sees and posts the streaming form', async () => {
+ const { robot } = await pairedRobot();
+ await t.call('POST', `/api/v1/robots/${robot.id}/operators`, { user: alex, body: { subject: operator.subject, role: 'operator' } });
+ const html = await (await page(`/panel/${robot.id}`, operator)).text();
+ assert.doesNotMatch(html, /data-streaming-form/, 'an operator sees no streaming switches');
+ assert.strictEqual((await form(`/robots/${robot.id}/streaming`, operator, 'media=on&live=on')).status, 403);
+ assert.strictEqual((await form(`/robots/${robot.id}/streaming`, stranger, 'media=on&live=on')).status, 403);
+ });
+
+ await check('an unpaired robot greys the switches out and says to pair a device', async () => {
+ const { robot } = await t.robot(alex, { name: 'No device yet' });
+ const html = await (await page(`/panel/${robot.id}`, alex)).text();
+ assert.match(html, /Pair a device first/);
+ assert.match(html, new RegExp(`href="/pair/${robot.id}"`));
+ assert.match(html, /name="media" value="on" disabled/);
+ });
+
+ await check('an OpenRe that does not answer leaves the panel up with the switches greyed out', async () => {
+ const dana = t.network.newUser('dana'); // a fresh owner: alex has used up the robot.manage minute by now
+ const { robot } = await pairedRobot(dana);
+ const real = t.domain.streaming.get;
+ t.domain.streaming.get = async () => { throw Object.assign(new Error('down'), { status: 503 }); };
+ try {
+ const r = await page(`/panel/${robot.id}`, dana);
+ assert.strictEqual(r.status, 200);
+ assert.match(await r.text(), /OpenRe did not answer just now/);
+ } finally { t.domain.streaming.get = real; }
+ });
+ await t.close();
+ const u = await boot({ openre: false });
+ const sam = u.network.newUser('sam');
+ await check('without OpenRe configured streaming reads not_configured and the write is 409', async () => {
+ const { robot, pairing } = await u.robot(sam);
+ const paired = await u.call('POST', '/api/v1/pair', { token: null, body: { robot: robot.id, code: pairing.code } });
+ assert.strictEqual(paired.status, 201, paired.text);
+ const got = await u.call('GET', `/api/v1/robots/${robot.id}/streaming`, { user: sam });
+ assert.strictEqual(got.status, 200, got.text);
+ assert.strictEqual(got.json.available, false);
+ assert.strictEqual(got.json.reason, 'not_configured');
+ const post = await u.call('POST', `/api/v1/robots/${robot.id}/streaming`, { user: sam, body: { to: 'live', on: true } });
+ assert.strictEqual(post.status, 409, post.text);
+ assert.strictEqual(post.json.code, 'bot.openre_not_configured');
+ });
+ await u.close();
+ done();
+})();