openvibers/openvibe.bot Commits
Bot: openvibe.bot serves its own front page (Frame + showcase, only what works today) instead of the Sites vision page. (#34)
Changes
@@@ -13,6 +13,7 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato- Devices (the machines that serve a robot) and the credentials that identify them: hashed at rest, rotated and revoked, never readable back.- Pairing: one-time codes with a short expiry, minted by Bot or, with `BOT_PAIRING_AUTHORITY=network`, by OpenVibe.Network.- The `/device` and `/control` WebSockets, the command gate and the latched e-stop.++- The front page, `GET /` (`server/web/home.js`): the OpenVibe Frame (`openvibe-shared/shell`) around `openvibe-shared/showcase` sections that say only what works today (pairing, the panel's controls, the gate and the e-stop, people and the queue, the read-only embed, the simulator and the kits with drivers), with its own CSP for the Frame's calls to the Network; the rest of the site keeps `default-src 'self'`. `/shared/` is openvibe-shared's serve handler.- 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.- Server-side ONVIF cameras (`camera.onvif`, `mapping.driver: onvif`): an in-process connector beside the simulator (`server/onvif`) attaches a camera configured with `BOT_ONVIF_CAMERAS`, writes a real `kind: server` device row, translates a `ptz` command into ONVIF `ContinuousMove`/`Stop` (with the profile's deadline as a deadman) and carries the camera's `GetStatus` readout in telemetry. Its credentials are secret references only (`username_ref`/`password_ref`).- The command audit (`command_audit`, kept 30 days) and the `bot.*` outbox events.@@@ -25,7 +26,7 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato- Identity, subjects and tokens (OpenVibe.Network) and the robot's record on Network.- Video: a device publishes over WHIP to OpenRe.Stream; Bot only hands out the URL at pairing.- The agent that runs on the robot; it ships with the T15 device-agent job.--- The public openvibe.bot front page and legal pages, built by OpenVibe.Sites.++- The legal pages (`/terms`, `/privacy`, `/dmca`) and `sitemap.xml`: the frozen OpenVibe.Sites files.## Devices@@@ -40,7 +41,7 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato- OpenVibe.Network: user and service tokens, node tokens, pairing when the authority is `network`, and identity resolution (a `@username` to a subject, and subjects to names, for the panel's People card).- OpenRe.Stream: the WHIP ingest base (`BOT_WHIP_BASE`) devices publish to.- OpenVibe.Billing: job usage readings go to `billing.usage.record` (`BOT_BILLING_URL`, `BOT_BILLING_TOKEN`); unset, they wait in `run_usage_outbox`.--- `openvibe-contracts` v0.96.0, `openvibe-sdk` v0.26.0 and `openvibe-shared` v2.5.0 (package.json).++- `openvibe-contracts` v0.96.0, `openvibe-sdk` v0.26.0 and `openvibe-shared` v2.10.1 (package.json).## Capabilities@@@ -90,6 +91,6 @@ Set `BOT_ONVIF_CAMERAS` (JSON, unset by default) to the server-side cameras Bot## Deploy files- `deploy/systemd/openvibe-bot.service` — systemd unit (`EnvironmentFile=/etc/openvibe/bot.env`, `PORT=4630`, hardening and `Restart=always`).--- `deploy/nginx/openvibe.bot.conf` — nginx reference site for openvibe.bot: TLS, www→apex, rate limits, and WebSocket upgrade headers for the device (`/device`) and operator (`/control`) sockets, passing the `Authorization` header through. It serves the existing Sites page from `/opt/openvibe.sites/dist/openvibe.bot` and shared assets from `/opt/openvibe.sites/dist/_shared`.++- `deploy/nginx/openvibe.bot.conf` — nginx reference site for openvibe.bot: TLS, www→apex, rate limits, and WebSocket upgrade headers for the device (`/device`) and operator (`/control`) sockets, passing the `Authorization` header through. It proxies the front page (`location = /`) and `/shared/` to the app, and serves the frozen OpenVibe.Sites legal pages, `robots.txt`, `sitemap.xml` and 404 page from `/opt/openvibe.sites/dist/openvibe.bot`.--OpenVibe.Sites keeps building the front page, legal pages and `/shared/` assets under `/opt/openvibe.sites/dist/`, but no longer generates `deploy/nginx/openvibe.bot.conf` (sites.json marks the vhost as owned by OpenVibe.Bot). A Sites deploy leaves an installed vhost it no longer generates in place, so the current Sites vhost keeps serving openvibe.bot until Bot's first deploy installs this file over it; nothing has to be removed by hand, and later Sites deploys never overwrite Bot's `/device` and `/control` routes.++OpenVibe.Sites keeps the frozen legal pages, `robots.txt` and `sitemap.xml` under `/opt/openvibe.sites/dist/openvibe.bot` (its front page there is no longer served), but no longer generates `deploy/nginx/openvibe.bot.conf` (sites.json marks the vhost as owned by OpenVibe.Bot). A Sites deploy leaves an installed vhost it no longer generates in place, so the current Sites vhost keeps serving openvibe.bot until Bot's first deploy installs this file over it; nothing has to be removed by hand, and later Sites deploys never overwrite Bot's `/device` and `/control` routes.@@@ -9,7 +9,7 @@"packages": ["openvibe-contracts v0.96.0","openvibe-sdk v0.26.0",-- "openvibe-shared v2.5.0"++ "openvibe-shared v2.10.1"],"capabilities": ["bot.robot.read",@@@ -47,5 +47,5 @@"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","server-side ONVIF cameras (profile camera.onvif, driver onvif, server/onvif): an in-process device beside the simulator attaches a camera configured with BOT_ONVIF_CAMERAS (its URL plus username_ref/password_ref secret references, never in the profile or in clear), writes a real kind server device row, translates ptz commands to ONVIF ContinuousMove/Stop with the profile deadline as a deadman, and carries the camera's GetStatus readout in telemetry; every request goes only to the configured host, with a timeout and no redirects"],-- "updated": "2026-10-04"++ "updated": "2026-10-07"}@@@ -4,8 +4,9 @@## Deployed to /etc/nginx/sites-available/openvibe.bot.conf — keep them in sync and reload nginx## after changing it. The Node app (port 4630, systemd unit openvibe-bot) serves the REST API and## terminates the device (`/device`), operator (`/control`) and watch (`/watch`) WebSockets; nginx terminates TLS,--## redirects www → apex, upgrades the three WebSocket routes, rate-limits sign-in and the API, and--## serves the existing OpenVibe.Sites public page and shared assets.++## redirects www → apex, upgrades the three WebSocket routes, rate-limits sign-in, the API and the pages,++## proxies the front page, /shared/, /robots.txt and /release.json to the app, and serves the frozen++## OpenVibe.Sites legal pages, sitemap.xml and 404 page.#### No proxy_cache: the app decides cacheability per response (no-store on the API), and device and## operator sockets must never be buffered or cached. Never log request bodies or the Authorization@@@ -166,11 +167,29 @@ server {# ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:4630/metrics) ──location = /metrics { return 404; }-- # ── Existing public page and shared browser assets ────────────-- # Sites continues to build these files after Bot takes ownership of the vhost.++ # ── The front page and the shared browser assets: Bot's own (server/web/home.js) ──++ # /shared/ is openvibe-shared's serve handler in the app, the same files the Frame loads everywhere.++ location = / {++ limit_req zone=ovbot_api burst=20 nodelay;++ limit_req_status 429;++ proxy_pass http://127.0.0.1:4630;++ }location ^~ /shared/ {-- alias /opt/openvibe.sites/dist/_shared/;++ proxy_pass http://127.0.0.1:4630;}++ # The Sites front page this replaced stays on disk until Sites drops it: never serve it by its file name.++ location = /index.html { return 301 /; }++ # robots.txt and release.json are the app's (server/web/routes.js, openvibe-shared/release); the Sites++ # copies were a placeholder's, and status.json ("stage": "placeholder") no longer describes the site.++ location = /robots.txt {++ proxy_pass http://127.0.0.1:4630;++ }++ location = /release.json {++ proxy_pass http://127.0.0.1:4630;++ }++ location = /status.json { return 404; }++++ # ── The legal pages, robots.txt, sitemap.xml and 404 page: still the frozen OpenVibe.Sites files ──# proxy_intercept_errors stays off, so this 404 page does not affect proxied responses.error_page 404 /404.html;@@@ -473,7 +473,7 @@ node token, and a service token is judged on `bot.job.dispatch` alone.| `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) |Not in `/api/v1`: `GET /api/health`, `GET /api/ready`, `GET /metrics`, `GET /release.json`, the--`/auth/*` SSO routes, `GET /` (a one-line text health placeholder), `GET /install` (a 302 to the installer),++`/auth/*` SSO routes, `GET /` (the front page, `server/web/home.js`), `GET /install` (a 302 to the installer),and the signed-in pages `GET|POST /robots`, `GET /pair/:id`, `GET /panel/:id`, `GET /panel/panel.{js,css}`,the panel's owner-only forms `POST /robots/:id/operators` (body `username`, `role` `operator|viewer`; a leading`@` is stripped; an unknown name is `422 bot.unknown_user` with `No OpenVibe account is called @<name>`) and@@@ -16,7 +16,7 @@"jsonwebtoken": "^9.0.2","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.96.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.5.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.10.1","pg": "^8.23.0","ws": "^8.18.0"},@@@ -888,9 +888,9 @@}},"node_modules/openvibe-shared": {-- "version": "2.5.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.5.0",-- "integrity": "sha512-fHuUS6SXM7ezatfAyqLm1V8B6puV9YXSnz6aG6XQbWx28qghRCCHeUF3lr4nxiCGdn9SyikYtIyLGL5r1ycp9Q==",++ "version": "2.10.1",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.10.1",++ "integrity": "sha512-6fUOw22bSvxg0K33BefAyAkNRkETe9euLNT0CHs2mLIvzYR1q38KiHRUQIWj8FESbVANF0pXMojhBz5TeqpMtg==","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0"@@@ -28,7 +28,7 @@"jsonwebtoken": "^9.0.2","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.96.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.5.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.10.1","pg": "^8.23.0","ws": "^8.18.0"},@@@ -3,7 +3,7 @@/*** Per-actor rate limits at /api/v1 (openvibe-sdk/limits). Counted by who calls, as api/auth.js resolved* req.principal. Past a limit the route answers 429 problem+json `rate_limited` with Retry-After.-- * Never limited: /api/health, /api/ready, /release.json, /metrics and the one-line GET /.++ * Never limited: /api/health, /api/ready, /release.json, /metrics and the front page GET /.*/const { createActorLimiter, createValkeyLimitStore, defaultActor } = require('openvibe-sdk/limits');const { userSubject } = require('../util');@@@ -7,7 +7,7 @@* GET /api/health, /api/ready, /release.json, /metrics (direct loopback callers only)* /api/v1/* the API (service tokens + user tokens, see api/v1.js)* /auth/* Network SSO session for people (the control WS accepts the ov_token cookie)-- * GET / a one-line text placeholder++ * GET / the front page (web/home.js)* /robots, /pair/:id, /panel/:id the signed-in pages (web/routes.js); a `sim` robot is driven by the* in-process simulator (sim/index.js), which server/index.js starts for existing ones at boot*@@@ -86,6 +86,8 @@ function createApp(opts = {}) {next();});app.use(cookieParser());++ // The OpenVibe Frame's scripts and the showcase stylesheet the front page links (content-hashed URLs).++ app.use('/shared', require('openvibe-shared/serve').handler());app.get('/api/health', (req, res, next) => outbox.status().then((events) => res.json({ok: true, service: 'bot', version: VERSION, devices_online: hub.onlineCount(), events,nnew file mode 100644@@@ -0,0 +1,114 @@++'use strict';++++/**++ * The front page of openvibe.bot (GET /), served by Bot itself in place of the frozen OpenVibe.Sites page:++ * the OpenVibe Frame (openvibe-shared/shell: navbar, footer, theme) around openvibe-shared/showcase sections.++ *++ * The copy says only what works today (README "Owns" and "Devices"): pairing with one pasted command, the++ * panel's controls, the command gate and the latched e-stop, people and the queue, the read-only embed, the++ * simulator, and the kits Bot has drivers for. Live video in the panel waits for OpenRe.Stream to play a++ * WHIP-published stream back, and the page says so rather than showing it.++ *++ * renderHome({ config, signedIn }) the whole document++ * HOME_CSP its Content-Security-Policy (the Frame's scripts and calls to the Network)++ */++const shell = require('openvibe-shared/shell');++const showcase = require('openvibe-shared/showcase');++const ovServe = require('openvibe-shared/serve');++const appIcon = require('openvibe-shared/app-icon');++++const SITE_NAME = 'OpenVibe.Bot';++const NETWORK_URL = 'https://openvibe.network';++const DESCRIPTION = 'An open control panel for robots: pair a robot with one pasted command and drive it from any browser, '++ + 'with roles, limits and an emergency stop on every command.';++++/** The panel's own pages keep `default-src 'self'`; the front page also runs the OpenVibe Frame. */++const HOME_CSP = [++ "default-src 'self'",++ // The Frame's theme-loader, navbar and footer are served from /shared; the inline boot is the shell's.++ "script-src 'self' 'unsafe-inline' https://openvibe.network",++ "style-src 'self' 'unsafe-inline' https://openvibe.network https://fonts.googleapis.com https://cdnjs.cloudflare.com",++ "font-src 'self' data: https://fonts.gstatic.com https://cdnjs.cloudflare.com",++ "img-src 'self' data: https:",++ // The navbar asks the Network for the session, menus and notifications (events: realtime and release notices).++ "connect-src 'self' https://openvibe.network https://events.openvibe.network",++ "frame-src 'self' https://openvibe.network",++ "frame-ancestors 'none'",++ "object-src 'none'",++ "base-uri 'self'",++ "form-action 'self' https://openvibe.network",++].join('; ');++++function sections({ signedIn }) {++ return showcase.hero({++ eyebrow: 'OpenVibe.Bot · alpha',++ title: 'Drive your robot', accent: 'from any browser.',++ lede: 'An open control panel for robots. Add a robot, pair it with one pasted command, and drive it with a touch joystick, '++ + 'the keyboard or a gamepad. Every command passes one gate, with roles, limits and an emergency stop that only you clear.',++ actions: signedIn++ ? [{ label: 'Your robots', href: '/robots', primary: true }, { label: 'How it works', href: '#how' }]++ : [{ label: 'Sign in with OpenVibe', href: '/auth/login?next=%2Frobots', primary: true }, { label: 'How it works', href: '#how' }],++ note: 'Alpha. Live video in the panel arrives when OpenRe.Stream can play a browser (WHIP) stream back; until then each camera keeps its place in the panel without a picture.',++ }) + showcase.steps({++ id: 'how',++ title: 'Online in three steps',++ lede: 'The robot connects out to OpenVibe, so there are no ports to open on your router.',++ items: [++ { title: 'Add a robot', text: 'Name it and pick its profile. A simulated rover works straight away, before any hardware exists.' },++ { title: 'Pair it', text: 'Paste one command on the robot\'s computer. It installs the OpenVibe device agent with a one-time code; the robot\'s credential is stored hashed and can be revoked any time.' },++ { title: 'Drive it', text: 'Open the robot\'s panel in any browser, phone included.' },++ ],++ }) + showcase.features({++ title: 'What the panel does',++ items: [++ { icon: 'ov:games', title: 'Real controls', text: 'A touch joystick, the keyboard or a gamepad, plus buttons and sliders, laid out from the robot\'s profile, with a latency meter.' },++ { icon: 'ov:error', title: 'Safe by default', text: 'Per-robot limits, cooldowns and a turn budget on every command. The emergency stop is latched: only the owner clears it, and a halt always gets through.' },++ { icon: 'ov:history', title: 'Every command on the record', text: 'Allowed or refused, each command is audited and kept for 30 days.' },++ { icon: 'ov:account', title: 'Share control', text: 'Add people by @username as operators or viewers, or open a queue where visitors take turns.' },++ { icon: 'ov:live', title: 'Show it on your channel', text: 'Let OpenVibe.Live frame the robot\'s read-only panel, so viewers follow its state as someone drives it.' },++ { icon: 'ov:code', title: 'An API with the same gate', text: 'A REST API for your own apps, held to the same roles, limits and e-stop as the panel.' },++ ],++ }) + showcase.features({++ title: 'Robots it drives today',++ items: [++ { icon: 'ov:check', title: 'Adeept 4WD Smart Car', text: 'The Raspberry Pi kit, with its parts list and build guide; a mecanum-wheel build has its own profile. The first robot on OpenVibe.Bot.' },++ { icon: 'ov:check', title: 'Cozmo', text: 'Through the device agent on a computer next to it.' },++ { icon: 'ov:check', title: 'A simulated rover', text: 'Runs inside Bot, so you can try the whole panel with no hardware.' },++ ],++ });++}++++function renderHome({ config, signedIn = false }) {++ const nav = {++ service: 'bot',++ apiBase: NETWORK_URL,++ links: [{ label: 'Robots', href: '/robots' }],++ history: { type: 'page', title: SITE_NAME },++ sessionUrl: '/auth/me',++ loginUrl: '/auth/login?next={path}',++ logoutUrl: '/auth/logout?next={path}',++ notificationsRealtime: true,++ };++ const footer = { service: 'bot', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME };++ return shell.page({++ name: SITE_NAME, service: 'bot', lang: 'en',++ title: `${SITE_NAME}: drive your robot from any browser`,++ siteName: SITE_NAME,++ description: DESCRIPTION,++ summary: DESCRIPTION,++ canonical: `${config.baseUrl}/`,++ robots: 'index, follow',++ navbar: nav, footer, home: '/', navLinks: [{ label: 'Robots', href: '/robots' }],++ head: [++ appIcon.headTags({ site: 'bot' }),++ '<link rel="manifest" href="/manifest.webmanifest">',++ `<link rel="stylesheet" href="${ovServe.url(showcase.STYLESHEET)}">`,++ ].join('\n'),++ body: `<div id="navbar-mount"></div>++<main id="main" class="page">++${sections({ signedIn })}++</main>`,++ });++}++++module.exports = { renderHome, HOME_CSP, sections };@@@ -1,7 +1,7 @@'use strict';/**-- * The non-API surface: a one-line text placeholder at `/` for the health of the process, `/install`, a 302 to++ * The non-API surface: the front page at `/` (web/home.js), `/robots.txt`, `/install`, a 302 to* OpenVibe.Node's installer script (the one-paste command, ADR-043), and the signed-in pages (the five-minute* path and the profile-rendered panel, plan T15 step 3):*@@@ -31,15 +31,16 @@ const { frameAncestors } = require('../config');const { BotError } = require('../util');const kits = require('../kits');const { renderPanel, renderEmbedRefused, renderRobotsPage, renderPairingPage } = require('./render');--const VERSION = require('../../package.json').version;++const { renderHome, HOME_CSP } = require('./home');const PUBLIC = path.join(__dirname, '..', '..', 'public');const HOLD_RESEND_MS = 150;function createWebRoutes(config, { domain = null, sim = null, onvif = null, limits = null, identity = null, hub = null, log = console } = {}) {const r = express.Router();-- r.get('/', (req, res) => res.type('text/plain').send(`OpenVibe.Bot ${VERSION} — ok (devices, pairing and control; API under /api/v1)\n`));-- r.get('/robots.txt', (req, res) => res.type('text/plain').send('User-agent: *\nDisallow: /\n'));++ // The public front page is crawlable; sign-in, the API and every signed-in or per-robot page are not.++ r.get('/robots.txt', (req, res) => res.type('text/plain').send(['User-agent: *', 'Allow: /$', 'Disallow: /auth/', 'Disallow: /api/',++ 'Disallow: /robots', 'Disallow: /pair/', 'Disallow: /panel/', 'Disallow: /install', `Sitemap: ${config.baseUrl}/sitemap.xml`, ''].join('\n')));// `curl -fsSL` follows the redirect. The target is config only (checked at boot): no query parameter steers it.r.get('/install', (req, res) => res.redirect(302, config.installer.sourceUrl));if (!domain) return r;@@@ -98,6 +99,15 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limires.type('html').send(renderRobotsPage({ robots, profiles, kits: kits.list(), ...extra, values }));}++ // The front page of openvibe.bot (server/web/home.js): public, indexable, with the OpenVibe Frame. Its two++ // actions differ by sign-in, so it is never stored by a shared cache.++ r.get('/', (req, res) => {++ res.setHeader('Content-Security-Policy', HOME_CSP);++ res.setHeader('Cache-Control', 'private, no-cache');++ res.setHeader('Vary', 'Cookie');++ res.type('html').send(renderHome({ config, signedIn: !!req.viewer }));++ });++r.get('/robots', page(async (req, res) => robotsPage(req, res, requireUser(req))));r.post('/robots', ...manage, express.urlencoded({ extended: false, limit: '8kb' }), page(async (req, res) => {@@@ -9,7 +9,7 @@ const { boot, check, done } = require('./helpers/app');const bob = t.network.newUser('bob');const stranger = t.network.newUser('stranger');-- await check('health, ready, release.json and the one-line GET /', async () => {++ await check('health, ready, release.json and the GET / front page', async () => {const h = await t.call('GET', '/api/health', { token: null });assert.strictEqual(h.json.service, 'bot');assert.strictEqual(h.json.devices_online, 0);@@@ -21,8 +21,7 @@ const { boot, check, done } = require('./helpers/app');assert.deepStrictEqual(require('openvibe-contracts').validate('registry.release-manifest@1', rel.json).errors, []);const home = await t.call('GET', '/', { token: null });assert.strictEqual(home.status, 200);-- assert.match(home.headers.get('content-type'), /text\/plain/);-- assert.strictEqual(home.text.trim().split('\n').length, 1);++ assert.match(home.headers.get('content-type'), /text\/html/); // the front page, test/home.test.js});await check('profiles are public reads; an unknown profile is a problem+json 404', async () => {nnew file mode 100644@@@ -0,0 +1,68 @@++'use strict';++// The front page of openvibe.bot (server/web/home.js): Bot serves it itself, with the OpenVibe Frame and the++// showcase sections, says only what works today, and keeps the rest of the site under `default-src 'self'`.++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const { boot, check, done } = require('./helpers/app');++++(async () => {++ const t = await boot({ openre: false });++ const alex = t.network.newUser('alex');++ const get = (p, user) => fetch(t.base + p, { redirect: 'manual', headers: user ? { Cookie: `ov_token=${t.network.signUser(user)}` } : {} });++++ await check('GET /: the front page, indexable, with the Frame and the showcase stylesheet', async () => {++ const r = await get('/');++ assert.strictEqual(r.status, 200);++ assert.match(r.headers.get('content-type'), /^text\/html/);++ assert.strictEqual(r.headers.get('cache-control'), 'private, no-cache');++ assert.strictEqual(r.headers.get('vary'), 'Cookie');++ const csp = r.headers.get('content-security-policy');++ assert.match(csp, /connect-src 'self' https:\/\/openvibe\.network https:\/\/events\.openvibe\.network/);++ assert.match(csp, /frame-ancestors 'none'/);++ const html = await r.text();++ assert.match(html, /<h1>Drive your robot<span class="sc-accent"> from any browser\.<\/span><\/h1>/);++ assert.match(html, /<meta name="robots" content="index, follow">/);++ assert.match(html, /<link rel="canonical" href="http:\/\/bot\.test\/">/);++ assert.match(html, /href="\/auth\/login\?next=%2Frobots">Sign in with OpenVibe/);++ assert.strictEqual((html.match(/<h1[ >]/g) || []).length, 1, 'one h1');++ const css = html.match(/href="(\/shared\/showcase\.css\?v=[0-9a-f]{12})"/);++ assert.ok(css, 'links the showcase stylesheet');++ const sheet = await get(css[1]);++ assert.strictEqual(sheet.status, 200, 'and serves it');++ assert.match(sheet.headers.get('content-type'), /^text\/css/);++ assert.match(html, /src="\/shared\/navbar\.js\?v=[0-9a-f]{12}"/);++ });++++ await check('GET /: no promise the service cannot keep (video waits for OpenRe; no store, no unsupported boards)', async () => {++ const html = await (await get('/')).text();++ assert.match(html, /Live video in the panel arrives when OpenRe\.Stream can play a browser \(WHIP\) stream back/);++ for (const claim of [/ESP32/, /ROS 2/, /Android/, /arrive paired/i, /\bbuy\b/i, /webhook/i]) assert.ok(!claim.test(html), `no ${claim}`);++ });++++ await check('GET /: signed in, the first action opens your robots', async () => {++ const html = await (await get('/', alex)).text();++ assert.match(html, /href="\/robots">Your robots/);++ assert.ok(!html.includes('Sign in with OpenVibe</a>'));++ });++++ await check('the rest of the site keeps its own CSP', async () => {++ const r = await get('/robots', alex);++ assert.strictEqual(r.headers.get('content-security-policy'), "default-src 'self'; frame-ancestors 'self'; object-src 'none'; base-uri 'self'");++ });++++ await check('the vhost proxies / and /shared/ to the app and keeps the legal pages on the Sites files', async () => {++ const conf = fs.readFileSync(path.join(__dirname, '..', 'deploy', 'nginx', 'openvibe.bot.conf'), 'utf8');++ assert.match(conf, /location \^~ \/shared\/ \{\s*proxy_pass http:\/\/127\.0\.0\.1:4630;\s*\}/);++ assert.ok(!conf.includes('alias /opt/openvibe.sites/dist/_shared/'), 'no second copy of the shared assets');++ assert.match(conf, /location = \/index\.html \{ return 301 \/; \}/, 'the replaced Sites page is never served by name');++ assert.match(conf, /location = \/robots\.txt \{\s*proxy_pass http:\/\/127\.0\.0\.1:4630;\s*\}/);++ assert.match(conf, /location = \/release\.json \{\s*proxy_pass http:\/\/127\.0\.0\.1:4630;\s*\}/);++ assert.match(conf, /location = \/status\.json \{ return 404; \}/);++ assert.match(conf, /location = \/ \{\s*limit_req zone=ovbot_api/);++ assert.match(conf, /root \/opt\/openvibe\.sites\/dist\/openvibe\.bot;/);++ });++++ await t.close();++ done();++})();@@@ -127,9 +127,12 @@ const allowedFor = (profile, role) => [...(DEFAULT_ALLOW[role] || []).filter((k)const control = (user) => t.ws('/control', { headers: cookie(user) });const poll = async (fn, ms = 3000) => { const end = Date.now() + ms; while (Date.now() < end) { const v = await fn(); if (v) return v; await t.wait(20); } return null; };-- await check('/, /robots.txt and /install are unchanged; the pages need a session', async () => {-- assert.match(await (await get('/')).text(), /^OpenVibe\.Bot .* — ok/);-- assert.strictEqual(await (await get('/robots.txt')).text(), 'User-agent: *\nDisallow: /\n');++ await check('/ is the front page (test/home.test.js); /robots.txt and /install are unchanged; the pages need a session', async () => {++ assert.match(await (await get('/')).text(), /<h1>Drive your robot/);++ const robots = await (await get('/robots.txt')).text();++ assert.match(robots, /^Allow: \/\$$/m, 'the front page is crawlable');++ for (const p of ['/auth/', '/api/', '/robots', '/pair/', '/panel/', '/install']) assert.ok(robots.includes(`Disallow: ${p}\n`), p);++ assert.match(robots, /^Sitemap: http:\/\/bot\.test\/sitemap\.xml$/m);assert.strictEqual((await get('/install')).status, 302);for (const p of ['/robots', '/panel/rob_x', '/pair/rob_x']) {const r = await get(p);
@@ -13,6 +13,7 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato
@@ -13,6 +13,7 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato
- Devices (the machines that serve a robot) and the credentials that identify them: hashed at rest, rotated and revoked, never readable back.
- Devices (the machines that serve a robot) and the credentials that identify them: hashed at rest, rotated and revoked, never readable back.
- Pairing: one-time codes with a short expiry, minted by Bot or, with `BOT_PAIRING_AUTHORITY=network`, by OpenVibe.Network.
- Pairing: one-time codes with a short expiry, minted by Bot or, with `BOT_PAIRING_AUTHORITY=network`, by OpenVibe.Network.
- 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 front page, `GET /` (`server/web/home.js`): the OpenVibe Frame (`openvibe-shared/shell`) around `openvibe-shared/showcase` sections that say only what works today (pairing, the panel's controls, the gate and the e-stop, people and the queue, the read-only embed, the simulator and the kits with drivers), with its own CSP for the Frame's calls to the Network; the rest of the site keeps `default-src 'self'`. `/shared/` is openvibe-shared's serve handler.
- 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.
- Server-side ONVIF cameras (`camera.onvif`, `mapping.driver: onvif`): an in-process connector beside the simulator (`server/onvif`) attaches a camera configured with `BOT_ONVIF_CAMERAS`, writes a real `kind: server` device row, translates a `ptz` command into ONVIF `ContinuousMove`/`Stop` (with the profile's deadline as a deadman) and carries the camera's `GetStatus` readout in telemetry. Its credentials are secret references only (`username_ref`/`password_ref`).
- Server-side ONVIF cameras (`camera.onvif`, `mapping.driver: onvif`): an in-process connector beside the simulator (`server/onvif`) attaches a camera configured with `BOT_ONVIF_CAMERAS`, writes a real `kind: server` device row, translates a `ptz` command into ONVIF `ContinuousMove`/`Stop` (with the profile's deadline as a deadman) and carries the camera's `GetStatus` readout in telemetry. Its credentials are secret references only (`username_ref`/`password_ref`).
- 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.
@@ -25,7 +26,7 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato
@@ -25,7 +26,7 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato
- Identity, subjects and tokens (OpenVibe.Network) and the robot's record on Network.
- Identity, subjects and tokens (OpenVibe.Network) and the robot's record on Network.
- Video: a device publishes over WHIP to OpenRe.Stream; Bot only hands out the URL at pairing.
- Video: a device publishes over WHIP to OpenRe.Stream; Bot only hands out the URL at pairing.
- The agent that runs on the robot; it ships with the T15 device-agent job.
- The agent that runs on the robot; it ships with the T15 device-agent job.
-- The public openvibe.bot front page and legal pages, built by OpenVibe.Sites.
+- The legal pages (`/terms`, `/privacy`, `/dmca`) and `sitemap.xml`: the frozen OpenVibe.Sites files.
## Devices
## Devices
@@ -40,7 +41,7 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato
@@ -40,7 +41,7 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato
- OpenVibe.Network: user and service tokens, node tokens, pairing when the authority is `network`, and identity resolution (a `@username` to a subject, and subjects to names, for the panel's People card).
- OpenVibe.Network: user and service tokens, node tokens, pairing when the authority is `network`, and identity resolution (a `@username` to a subject, and subjects to names, for the panel's People card).
- OpenRe.Stream: the WHIP ingest base (`BOT_WHIP_BASE`) devices publish to.
- OpenRe.Stream: the WHIP ingest base (`BOT_WHIP_BASE`) devices publish to.
- OpenVibe.Billing: job usage readings go to `billing.usage.record` (`BOT_BILLING_URL`, `BOT_BILLING_TOKEN`); unset, they wait in `run_usage_outbox`.
- OpenVibe.Billing: job usage readings go to `billing.usage.record` (`BOT_BILLING_URL`, `BOT_BILLING_TOKEN`); unset, they wait in `run_usage_outbox`.
-- `openvibe-contracts` v0.96.0, `openvibe-sdk` v0.26.0 and `openvibe-shared` v2.5.0 (package.json).
+- `openvibe-contracts` v0.96.0, `openvibe-sdk` v0.26.0 and `openvibe-shared` v2.10.1 (package.json).
## Capabilities
## Capabilities
@@ -90,6 +91,6 @@ Set `BOT_ONVIF_CAMERAS` (JSON, unset by default) to the server-side cameras Bot
@@ -90,6 +91,6 @@ Set `BOT_ONVIF_CAMERAS` (JSON, unset by default) to the server-side cameras Bot
## Deploy files
## Deploy files
- `deploy/systemd/openvibe-bot.service` — systemd unit (`EnvironmentFile=/etc/openvibe/bot.env`, `PORT=4630`, hardening and `Restart=always`).
- `deploy/systemd/openvibe-bot.service` — systemd unit (`EnvironmentFile=/etc/openvibe/bot.env`, `PORT=4630`, hardening and `Restart=always`).
-- `deploy/nginx/openvibe.bot.conf` — nginx reference site for openvibe.bot: TLS, www→apex, rate limits, and WebSocket upgrade headers for the device (`/device`) and operator (`/control`) sockets, passing the `Authorization` header through. It serves the existing Sites page from `/opt/openvibe.sites/dist/openvibe.bot` and shared assets from `/opt/openvibe.sites/dist/_shared`.
+- `deploy/nginx/openvibe.bot.conf` — nginx reference site for openvibe.bot: TLS, www→apex, rate limits, and WebSocket upgrade headers for the device (`/device`) and operator (`/control`) sockets, passing the `Authorization` header through. It proxies the front page (`location = /`) and `/shared/` to the app, and serves the frozen OpenVibe.Sites legal pages, `robots.txt`, `sitemap.xml` and 404 page from `/opt/openvibe.sites/dist/openvibe.bot`.
-OpenVibe.Sites keeps building the front page, legal pages and `/shared/` assets under `/opt/openvibe.sites/dist/`, but no longer generates `deploy/nginx/openvibe.bot.conf` (sites.json marks the vhost as owned by OpenVibe.Bot). A Sites deploy leaves an installed vhost it no longer generates in place, so the current Sites vhost keeps serving openvibe.bot until Bot's first deploy installs this file over it; nothing has to be removed by hand, and later Sites deploys never overwrite Bot's `/device` and `/control` routes.
+OpenVibe.Sites keeps the frozen legal pages, `robots.txt` and `sitemap.xml` under `/opt/openvibe.sites/dist/openvibe.bot` (its front page there is no longer served), but no longer generates `deploy/nginx/openvibe.bot.conf` (sites.json marks the vhost as owned by OpenVibe.Bot). A Sites deploy leaves an installed vhost it no longer generates in place, so the current Sites vhost keeps serving openvibe.bot until Bot's first deploy installs this file over it; nothing has to be removed by hand, and later Sites deploys never overwrite Bot's `/device` and `/control` routes.
@@ -9,7 +9,7 @@
@@ -9,7 +9,7 @@
"packages": [
"packages": [
"openvibe-contracts v0.96.0",
"openvibe-contracts v0.96.0",
"openvibe-sdk v0.26.0",
"openvibe-sdk v0.26.0",
- "openvibe-shared v2.5.0"
+ "openvibe-shared v2.10.1"
],
],
"capabilities": [
"capabilities": [
"bot.robot.read",
"bot.robot.read",
@@ -47,5 +47,5 @@
@@ -47,5 +47,5 @@
"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",
"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",
"server-side ONVIF cameras (profile camera.onvif, driver onvif, server/onvif): an in-process device beside the simulator attaches a camera configured with BOT_ONVIF_CAMERAS (its URL plus username_ref/password_ref secret references, never in the profile or in clear), writes a real kind server device row, translates ptz commands to ONVIF ContinuousMove/Stop with the profile deadline as a deadman, and carries the camera's GetStatus readout in telemetry; every request goes only to the configured host, with a timeout and no redirects"
"server-side ONVIF cameras (profile camera.onvif, driver onvif, server/onvif): an in-process device beside the simulator attaches a camera configured with BOT_ONVIF_CAMERAS (its URL plus username_ref/password_ref secret references, never in the profile or in clear), writes a real kind server device row, translates ptz commands to ONVIF ContinuousMove/Stop with the profile deadline as a deadman, and carries the camera's GetStatus readout in telemetry; every request goes only to the configured host, with a timeout and no redirects"
],
],
- "updated": "2026-10-04"
+ "updated": "2026-10-07"
}
}
@@ -4,8 +4,9 @@
@@ -4,8 +4,9 @@
## Deployed to /etc/nginx/sites-available/openvibe.bot.conf — keep them in sync and reload nginx
## Deployed to /etc/nginx/sites-available/openvibe.bot.conf — keep them in sync and reload nginx
## after changing it. The Node app (port 4630, systemd unit openvibe-bot) serves the REST API and
## after changing it. The Node app (port 4630, systemd unit openvibe-bot) serves the REST API and
## terminates the device (`/device`), operator (`/control`) and watch (`/watch`) WebSockets; nginx terminates TLS,
## terminates the device (`/device`), operator (`/control`) and watch (`/watch`) WebSockets; nginx terminates TLS,
-## redirects www → apex, upgrades the three WebSocket routes, rate-limits sign-in and the API, and
-## serves the existing OpenVibe.Sites public page and shared assets.
+## redirects www → apex, upgrades the three WebSocket routes, rate-limits sign-in, the API and the pages,
+## proxies the front page, /shared/, /robots.txt and /release.json to the app, and serves the frozen
+## OpenVibe.Sites legal pages, sitemap.xml and 404 page.
##
##
## No proxy_cache: the app decides cacheability per response (no-store on the API), and device and
## No proxy_cache: the app decides cacheability per response (no-store on the API), and device and
## operator sockets must never be buffered or cached. Never log request bodies or the Authorization
## operator sockets must never be buffered or cached. Never log request bodies or the Authorization
@@ -166,11 +167,29 @@ server {
@@ -166,11 +167,29 @@ server {
# ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:4630/metrics) ──
# ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:4630/metrics) ──
location = /metrics { return 404; }
location = /metrics { return 404; }
- # ── Existing public page and shared browser assets ────────────
- # Sites continues to build these files after Bot takes ownership of the vhost.
+ # ── The front page and the shared browser assets: Bot's own (server/web/home.js) ──
+ # /shared/ is openvibe-shared's serve handler in the app, the same files the Frame loads everywhere.
+ location = / {
+ limit_req zone=ovbot_api burst=20 nodelay;
+ limit_req_status 429;
+ proxy_pass http://127.0.0.1:4630;
+ }
location ^~ /shared/ {
location ^~ /shared/ {
- alias /opt/openvibe.sites/dist/_shared/;
+ proxy_pass http://127.0.0.1:4630;
}
}
+ # The Sites front page this replaced stays on disk until Sites drops it: never serve it by its file name.
+ location = /index.html { return 301 /; }
+ # robots.txt and release.json are the app's (server/web/routes.js, openvibe-shared/release); the Sites
+ # copies were a placeholder's, and status.json ("stage": "placeholder") no longer describes the site.
+ location = /robots.txt {
+ proxy_pass http://127.0.0.1:4630;
+ }
+ location = /release.json {
+ proxy_pass http://127.0.0.1:4630;
+ }
+ location = /status.json { return 404; }
+
+ # ── The legal pages, robots.txt, sitemap.xml and 404 page: still the frozen OpenVibe.Sites files ──
# proxy_intercept_errors stays off, so this 404 page does not affect proxied responses.
# proxy_intercept_errors stays off, so this 404 page does not affect proxied responses.
error_page 404 /404.html;
error_page 404 /404.html;
@@ -473,7 +473,7 @@ node token, and a service token is judged on `bot.job.dispatch` alone.
@@ -473,7 +473,7 @@ node token, and a service token is judged on `bot.job.dispatch` alone.
| `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) |
Not in `/api/v1`: `GET /api/health`, `GET /api/ready`, `GET /metrics`, `GET /release.json`, the
Not in `/api/v1`: `GET /api/health`, `GET /api/ready`, `GET /metrics`, `GET /release.json`, the
-`/auth/*` SSO routes, `GET /` (a one-line text health placeholder), `GET /install` (a 302 to the installer),
+`/auth/*` SSO routes, `GET /` (the front page, `server/web/home.js`), `GET /install` (a 302 to the installer),
and the signed-in pages `GET|POST /robots`, `GET /pair/:id`, `GET /panel/:id`, `GET /panel/panel.{js,css}`,
and the signed-in pages `GET|POST /robots`, `GET /pair/:id`, `GET /panel/:id`, `GET /panel/panel.{js,css}`,
the panel's owner-only forms `POST /robots/:id/operators` (body `username`, `role` `operator|viewer`; a leading
the panel's owner-only forms `POST /robots/:id/operators` (body `username`, `role` `operator|viewer`; a leading
`@` is stripped; an unknown name is `422 bot.unknown_user` with `No OpenVibe account is called @<name>`) and
`@` is stripped; an unknown name is `422 bot.unknown_user` with `No OpenVibe account is called @<name>`) and
@@ -16,7 +16,7 @@
@@ -16,7 +16,7 @@
"jsonwebtoken": "^9.0.2",
"jsonwebtoken": "^9.0.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.96.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.96.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.5.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.10.1",
"pg": "^8.23.0",
"pg": "^8.23.0",
"ws": "^8.18.0"
"ws": "^8.18.0"
},
},
@@ -888,9 +888,9 @@
@@ -888,9 +888,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
- "version": "2.5.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.5.0",
- "integrity": "sha512-fHuUS6SXM7ezatfAyqLm1V8B6puV9YXSnz6aG6XQbWx28qghRCCHeUF3lr4nxiCGdn9SyikYtIyLGL5r1ycp9Q==",
+ "version": "2.10.1",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.10.1",
+ "integrity": "sha512-6fUOw22bSvxg0K33BefAyAkNRkETe9euLNT0CHs2mLIvzYR1q38KiHRUQIWj8FESbVANF0pXMojhBz5TeqpMtg==",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
@@ -28,7 +28,7 @@
@@ -28,7 +28,7 @@
"jsonwebtoken": "^9.0.2",
"jsonwebtoken": "^9.0.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.96.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.96.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.5.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.10.1",
"pg": "^8.23.0",
"pg": "^8.23.0",
"ws": "^8.18.0"
"ws": "^8.18.0"
},
},
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
/**
/**
* Per-actor rate limits at /api/v1 (openvibe-sdk/limits). Counted by who calls, as api/auth.js resolved
* Per-actor rate limits at /api/v1 (openvibe-sdk/limits). Counted by who calls, as api/auth.js resolved
* req.principal. Past a limit the route answers 429 problem+json `rate_limited` with Retry-After.
* req.principal. Past a limit the route answers 429 problem+json `rate_limited` with Retry-After.
- * Never limited: /api/health, /api/ready, /release.json, /metrics and the one-line GET /.
+ * Never limited: /api/health, /api/ready, /release.json, /metrics and the front page GET /.
*/
*/
const { createActorLimiter, createValkeyLimitStore, defaultActor } = require('openvibe-sdk/limits');
const { createActorLimiter, createValkeyLimitStore, defaultActor } = require('openvibe-sdk/limits');
const { userSubject } = require('../util');
const { userSubject } = require('../util');
@@ -7,7 +7,7 @@
@@ -7,7 +7,7 @@
* GET /api/health, /api/ready, /release.json, /metrics (direct loopback callers only)
* GET /api/health, /api/ready, /release.json, /metrics (direct loopback callers only)
* /api/v1/* the API (service tokens + user tokens, see api/v1.js)
* /api/v1/* the API (service tokens + user tokens, see api/v1.js)
* /auth/* Network SSO session for people (the control WS accepts the ov_token cookie)
* /auth/* Network SSO session for people (the control WS accepts the ov_token cookie)
- * GET / a one-line text placeholder
+ * GET / the front page (web/home.js)
* /robots, /pair/:id, /panel/:id the signed-in pages (web/routes.js); a `sim` robot is driven by the
* /robots, /pair/:id, /panel/:id the signed-in pages (web/routes.js); a `sim` robot is driven by the
* in-process simulator (sim/index.js), which server/index.js starts for existing ones at boot
* in-process simulator (sim/index.js), which server/index.js starts for existing ones at boot
*
*
@@ -86,6 +86,8 @@ function createApp(opts = {}) {
@@ -86,6 +86,8 @@ function createApp(opts = {}) {
next();
next();
});
});
app.use(cookieParser());
app.use(cookieParser());
+ // The OpenVibe Frame's scripts and the showcase stylesheet the front page links (content-hashed URLs).
+ app.use('/shared', require('openvibe-shared/serve').handler());
app.get('/api/health', (req, res, next) => outbox.status().then((events) => res.json({
app.get('/api/health', (req, res, next) => outbox.status().then((events) => res.json({
ok: true, service: 'bot', version: VERSION, devices_online: hub.onlineCount(), events,
ok: true, service: 'bot', version: VERSION, devices_online: hub.onlineCount(), events,
new file mode 100644
new file mode 100644
@@ -0,0 +1,114 @@
@@ -0,0 +1,114 @@
+'use strict';
+
+/**
+ * The front page of openvibe.bot (GET /), served by Bot itself in place of the frozen OpenVibe.Sites page:
+ * the OpenVibe Frame (openvibe-shared/shell: navbar, footer, theme) around openvibe-shared/showcase sections.
+ *
+ * The copy says only what works today (README "Owns" and "Devices"): pairing with one pasted command, the
+ * panel's controls, the command gate and the latched e-stop, people and the queue, the read-only embed, the
+ * simulator, and the kits Bot has drivers for. Live video in the panel waits for OpenRe.Stream to play a
+ * WHIP-published stream back, and the page says so rather than showing it.
+ *
+ * renderHome({ config, signedIn }) the whole document
+ * HOME_CSP its Content-Security-Policy (the Frame's scripts and calls to the Network)
+ */
+const shell = require('openvibe-shared/shell');
+const showcase = require('openvibe-shared/showcase');
+const ovServe = require('openvibe-shared/serve');
+const appIcon = require('openvibe-shared/app-icon');
+
+const SITE_NAME = 'OpenVibe.Bot';
+const NETWORK_URL = 'https://openvibe.network';
+const DESCRIPTION = 'An open control panel for robots: pair a robot with one pasted command and drive it from any browser, '
+ + 'with roles, limits and an emergency stop on every command.';
+
+/** The panel's own pages keep `default-src 'self'`; the front page also runs the OpenVibe Frame. */
+const HOME_CSP = [
+ "default-src 'self'",
+ // The Frame's theme-loader, navbar and footer are served from /shared; the inline boot is the shell's.
+ "script-src 'self' 'unsafe-inline' https://openvibe.network",
+ "style-src 'self' 'unsafe-inline' https://openvibe.network https://fonts.googleapis.com https://cdnjs.cloudflare.com",
+ "font-src 'self' data: https://fonts.gstatic.com https://cdnjs.cloudflare.com",
+ "img-src 'self' data: https:",
+ // The navbar asks the Network for the session, menus and notifications (events: realtime and release notices).
+ "connect-src 'self' https://openvibe.network https://events.openvibe.network",
+ "frame-src 'self' https://openvibe.network",
+ "frame-ancestors 'none'",
+ "object-src 'none'",
+ "base-uri 'self'",
+ "form-action 'self' https://openvibe.network",
+].join('; ');
+
+function sections({ signedIn }) {
+ return showcase.hero({
+ eyebrow: 'OpenVibe.Bot · alpha',
+ title: 'Drive your robot', accent: 'from any browser.',
+ lede: 'An open control panel for robots. Add a robot, pair it with one pasted command, and drive it with a touch joystick, '
+ + 'the keyboard or a gamepad. Every command passes one gate, with roles, limits and an emergency stop that only you clear.',
+ actions: signedIn
+ ? [{ label: 'Your robots', href: '/robots', primary: true }, { label: 'How it works', href: '#how' }]
+ : [{ label: 'Sign in with OpenVibe', href: '/auth/login?next=%2Frobots', primary: true }, { label: 'How it works', href: '#how' }],
+ note: 'Alpha. Live video in the panel arrives when OpenRe.Stream can play a browser (WHIP) stream back; until then each camera keeps its place in the panel without a picture.',
+ }) + showcase.steps({
+ id: 'how',
+ title: 'Online in three steps',
+ lede: 'The robot connects out to OpenVibe, so there are no ports to open on your router.',
+ items: [
+ { title: 'Add a robot', text: 'Name it and pick its profile. A simulated rover works straight away, before any hardware exists.' },
+ { title: 'Pair it', text: 'Paste one command on the robot\'s computer. It installs the OpenVibe device agent with a one-time code; the robot\'s credential is stored hashed and can be revoked any time.' },
+ { title: 'Drive it', text: 'Open the robot\'s panel in any browser, phone included.' },
+ ],
+ }) + showcase.features({
+ title: 'What the panel does',
+ items: [
+ { icon: 'ov:games', title: 'Real controls', text: 'A touch joystick, the keyboard or a gamepad, plus buttons and sliders, laid out from the robot\'s profile, with a latency meter.' },
+ { icon: 'ov:error', title: 'Safe by default', text: 'Per-robot limits, cooldowns and a turn budget on every command. The emergency stop is latched: only the owner clears it, and a halt always gets through.' },
+ { icon: 'ov:history', title: 'Every command on the record', text: 'Allowed or refused, each command is audited and kept for 30 days.' },
+ { icon: 'ov:account', title: 'Share control', text: 'Add people by @username as operators or viewers, or open a queue where visitors take turns.' },
+ { icon: 'ov:live', title: 'Show it on your channel', text: 'Let OpenVibe.Live frame the robot\'s read-only panel, so viewers follow its state as someone drives it.' },
+ { icon: 'ov:code', title: 'An API with the same gate', text: 'A REST API for your own apps, held to the same roles, limits and e-stop as the panel.' },
+ ],
+ }) + showcase.features({
+ title: 'Robots it drives today',
+ items: [
+ { icon: 'ov:check', title: 'Adeept 4WD Smart Car', text: 'The Raspberry Pi kit, with its parts list and build guide; a mecanum-wheel build has its own profile. The first robot on OpenVibe.Bot.' },
+ { icon: 'ov:check', title: 'Cozmo', text: 'Through the device agent on a computer next to it.' },
+ { icon: 'ov:check', title: 'A simulated rover', text: 'Runs inside Bot, so you can try the whole panel with no hardware.' },
+ ],
+ });
+}
+
+function renderHome({ config, signedIn = false }) {
+ const nav = {
+ service: 'bot',
+ apiBase: NETWORK_URL,
+ links: [{ label: 'Robots', href: '/robots' }],
+ history: { type: 'page', title: SITE_NAME },
+ sessionUrl: '/auth/me',
+ loginUrl: '/auth/login?next={path}',
+ logoutUrl: '/auth/logout?next={path}',
+ notificationsRealtime: true,
+ };
+ const footer = { service: 'bot', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME };
+ return shell.page({
+ name: SITE_NAME, service: 'bot', lang: 'en',
+ title: `${SITE_NAME}: drive your robot from any browser`,
+ siteName: SITE_NAME,
+ description: DESCRIPTION,
+ summary: DESCRIPTION,
+ canonical: `${config.baseUrl}/`,
+ robots: 'index, follow',
+ navbar: nav, footer, home: '/', navLinks: [{ label: 'Robots', href: '/robots' }],
+ head: [
+ appIcon.headTags({ site: 'bot' }),
+ '<link rel="manifest" href="/manifest.webmanifest">',
+ `<link rel="stylesheet" href="${ovServe.url(showcase.STYLESHEET)}">`,
+ ].join('\n'),
+ body: `<div id="navbar-mount"></div>
+<main id="main" class="page">
+${sections({ signedIn })}
+</main>`,
+ });
+}
+
+module.exports = { renderHome, HOME_CSP, sections };
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
'use strict';
'use strict';
/**
/**
- * The non-API surface: a one-line text placeholder at `/` for the health of the process, `/install`, a 302 to
+ * The non-API surface: the front page at `/` (web/home.js), `/robots.txt`, `/install`, a 302 to
* OpenVibe.Node's installer script (the one-paste command, ADR-043), and the signed-in pages (the five-minute
* OpenVibe.Node's installer script (the one-paste command, ADR-043), and the signed-in pages (the five-minute
* path and the profile-rendered panel, plan T15 step 3):
* path and the profile-rendered panel, plan T15 step 3):
*
*
@@ -31,15 +31,16 @@ const { frameAncestors } = require('../config');
@@ -31,15 +31,16 @@ const { frameAncestors } = require('../config');
const { BotError } = require('../util');
const { BotError } = require('../util');
const kits = require('../kits');
const kits = require('../kits');
const { renderPanel, renderEmbedRefused, renderRobotsPage, renderPairingPage } = require('./render');
const { renderPanel, renderEmbedRefused, renderRobotsPage, renderPairingPage } = require('./render');
-const VERSION = require('../../package.json').version;
+const { renderHome, HOME_CSP } = require('./home');
const PUBLIC = path.join(__dirname, '..', '..', 'public');
const PUBLIC = path.join(__dirname, '..', '..', 'public');
const HOLD_RESEND_MS = 150;
const HOLD_RESEND_MS = 150;
function createWebRoutes(config, { domain = null, sim = null, onvif = null, limits = null, identity = null, hub = null, log = console } = {}) {
function createWebRoutes(config, { domain = null, sim = null, onvif = null, limits = null, identity = null, hub = null, log = console } = {}) {
const r = express.Router();
const r = express.Router();
- r.get('/', (req, res) => res.type('text/plain').send(`OpenVibe.Bot ${VERSION} — ok (devices, pairing and control; API under /api/v1)\n`));
- r.get('/robots.txt', (req, res) => res.type('text/plain').send('User-agent: *\nDisallow: /\n'));
+ // The public front page is crawlable; sign-in, the API and every signed-in or per-robot page are not.
+ r.get('/robots.txt', (req, res) => res.type('text/plain').send(['User-agent: *', 'Allow: /$', 'Disallow: /auth/', 'Disallow: /api/',
+ 'Disallow: /robots', 'Disallow: /pair/', 'Disallow: /panel/', 'Disallow: /install', `Sitemap: ${config.baseUrl}/sitemap.xml`, ''].join('\n')));
// `curl -fsSL` follows the redirect. The target is config only (checked at boot): no query parameter steers it.
// `curl -fsSL` follows the redirect. The target is config only (checked at boot): no query parameter steers it.
r.get('/install', (req, res) => res.redirect(302, config.installer.sourceUrl));
r.get('/install', (req, res) => res.redirect(302, config.installer.sourceUrl));
if (!domain) return r;
if (!domain) return r;
@@ -98,6 +99,15 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
@@ -98,6 +99,15 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
res.type('html').send(renderRobotsPage({ robots, profiles, kits: kits.list(), ...extra, values }));
res.type('html').send(renderRobotsPage({ robots, profiles, kits: kits.list(), ...extra, values }));
}
}
+ // The front page of openvibe.bot (server/web/home.js): public, indexable, with the OpenVibe Frame. Its two
+ // actions differ by sign-in, so it is never stored by a shared cache.
+ r.get('/', (req, res) => {
+ res.setHeader('Content-Security-Policy', HOME_CSP);
+ res.setHeader('Cache-Control', 'private, no-cache');
+ res.setHeader('Vary', 'Cookie');
+ res.type('html').send(renderHome({ config, signedIn: !!req.viewer }));
+ });
+
r.get('/robots', page(async (req, res) => robotsPage(req, res, requireUser(req))));
r.get('/robots', page(async (req, res) => robotsPage(req, res, requireUser(req))));
r.post('/robots', ...manage, express.urlencoded({ extended: false, limit: '8kb' }), page(async (req, res) => {
r.post('/robots', ...manage, express.urlencoded({ extended: false, limit: '8kb' }), page(async (req, res) => {
@@ -9,7 +9,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -9,7 +9,7 @@ const { boot, check, done } = require('./helpers/app');
const bob = t.network.newUser('bob');
const bob = t.network.newUser('bob');
const stranger = t.network.newUser('stranger');
const stranger = t.network.newUser('stranger');
- await check('health, ready, release.json and the one-line GET /', async () => {
+ await check('health, ready, release.json and the GET / front page', async () => {
const h = await t.call('GET', '/api/health', { token: null });
const h = await t.call('GET', '/api/health', { token: null });
assert.strictEqual(h.json.service, 'bot');
assert.strictEqual(h.json.service, 'bot');
assert.strictEqual(h.json.devices_online, 0);
assert.strictEqual(h.json.devices_online, 0);
@@ -21,8 +21,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -21,8 +21,7 @@ const { boot, check, done } = require('./helpers/app');
assert.deepStrictEqual(require('openvibe-contracts').validate('registry.release-manifest@1', rel.json).errors, []);
assert.deepStrictEqual(require('openvibe-contracts').validate('registry.release-manifest@1', rel.json).errors, []);
const home = await t.call('GET', '/', { token: null });
const home = await t.call('GET', '/', { token: null });
assert.strictEqual(home.status, 200);
assert.strictEqual(home.status, 200);
- assert.match(home.headers.get('content-type'), /text\/plain/);
- assert.strictEqual(home.text.trim().split('\n').length, 1);
+ assert.match(home.headers.get('content-type'), /text\/html/); // the front page, test/home.test.js
});
});
await check('profiles are public reads; an unknown profile is a problem+json 404', async () => {
await check('profiles are public reads; an unknown profile is a problem+json 404', async () => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,68 @@
@@ -0,0 +1,68 @@
+'use strict';
+// The front page of openvibe.bot (server/web/home.js): Bot serves it itself, with the OpenVibe Frame and the
+// showcase sections, says only what works today, and keeps the rest of the site under `default-src 'self'`.
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const { boot, check, done } = require('./helpers/app');
+
+(async () => {
+ const t = await boot({ openre: false });
+ const alex = t.network.newUser('alex');
+ const get = (p, user) => fetch(t.base + p, { redirect: 'manual', headers: user ? { Cookie: `ov_token=${t.network.signUser(user)}` } : {} });
+
+ await check('GET /: the front page, indexable, with the Frame and the showcase stylesheet', async () => {
+ const r = await get('/');
+ assert.strictEqual(r.status, 200);
+ assert.match(r.headers.get('content-type'), /^text\/html/);
+ assert.strictEqual(r.headers.get('cache-control'), 'private, no-cache');
+ assert.strictEqual(r.headers.get('vary'), 'Cookie');
+ const csp = r.headers.get('content-security-policy');
+ assert.match(csp, /connect-src 'self' https:\/\/openvibe\.network https:\/\/events\.openvibe\.network/);
+ assert.match(csp, /frame-ancestors 'none'/);
+ const html = await r.text();
+ assert.match(html, /<h1>Drive your robot<span class="sc-accent"> from any browser\.<\/span><\/h1>/);
+ assert.match(html, /<meta name="robots" content="index, follow">/);
+ assert.match(html, /<link rel="canonical" href="http:\/\/bot\.test\/">/);
+ assert.match(html, /href="\/auth\/login\?next=%2Frobots">Sign in with OpenVibe/);
+ assert.strictEqual((html.match(/<h1[ >]/g) || []).length, 1, 'one h1');
+ const css = html.match(/href="(\/shared\/showcase\.css\?v=[0-9a-f]{12})"/);
+ assert.ok(css, 'links the showcase stylesheet');
+ const sheet = await get(css[1]);
+ assert.strictEqual(sheet.status, 200, 'and serves it');
+ assert.match(sheet.headers.get('content-type'), /^text\/css/);
+ assert.match(html, /src="\/shared\/navbar\.js\?v=[0-9a-f]{12}"/);
+ });
+
+ await check('GET /: no promise the service cannot keep (video waits for OpenRe; no store, no unsupported boards)', async () => {
+ const html = await (await get('/')).text();
+ assert.match(html, /Live video in the panel arrives when OpenRe\.Stream can play a browser \(WHIP\) stream back/);
+ for (const claim of [/ESP32/, /ROS 2/, /Android/, /arrive paired/i, /\bbuy\b/i, /webhook/i]) assert.ok(!claim.test(html), `no ${claim}`);
+ });
+
+ await check('GET /: signed in, the first action opens your robots', async () => {
+ const html = await (await get('/', alex)).text();
+ assert.match(html, /href="\/robots">Your robots/);
+ assert.ok(!html.includes('Sign in with OpenVibe</a>'));
+ });
+
+ await check('the rest of the site keeps its own CSP', async () => {
+ const r = await get('/robots', alex);
+ assert.strictEqual(r.headers.get('content-security-policy'), "default-src 'self'; frame-ancestors 'self'; object-src 'none'; base-uri 'self'");
+ });
+
+ await check('the vhost proxies / and /shared/ to the app and keeps the legal pages on the Sites files', async () => {
+ const conf = fs.readFileSync(path.join(__dirname, '..', 'deploy', 'nginx', 'openvibe.bot.conf'), 'utf8');
+ assert.match(conf, /location \^~ \/shared\/ \{\s*proxy_pass http:\/\/127\.0\.0\.1:4630;\s*\}/);
+ assert.ok(!conf.includes('alias /opt/openvibe.sites/dist/_shared/'), 'no second copy of the shared assets');
+ assert.match(conf, /location = \/index\.html \{ return 301 \/; \}/, 'the replaced Sites page is never served by name');
+ assert.match(conf, /location = \/robots\.txt \{\s*proxy_pass http:\/\/127\.0\.0\.1:4630;\s*\}/);
+ assert.match(conf, /location = \/release\.json \{\s*proxy_pass http:\/\/127\.0\.0\.1:4630;\s*\}/);
+ assert.match(conf, /location = \/status\.json \{ return 404; \}/);
+ assert.match(conf, /location = \/ \{\s*limit_req zone=ovbot_api/);
+ assert.match(conf, /root \/opt\/openvibe\.sites\/dist\/openvibe\.bot;/);
+ });
+
+ await t.close();
+ done();
+})();
@@ -127,9 +127,12 @@ const allowedFor = (profile, role) => [...(DEFAULT_ALLOW[role] || []).filter((k)
@@ -127,9 +127,12 @@ const allowedFor = (profile, role) => [...(DEFAULT_ALLOW[role] || []).filter((k)
const control = (user) => t.ws('/control', { headers: cookie(user) });
const control = (user) => t.ws('/control', { headers: cookie(user) });
const poll = async (fn, ms = 3000) => { const end = Date.now() + ms; while (Date.now() < end) { const v = await fn(); if (v) return v; await t.wait(20); } return null; };
const poll = async (fn, ms = 3000) => { const end = Date.now() + ms; while (Date.now() < end) { const v = await fn(); if (v) return v; await t.wait(20); } return null; };
- await check('/, /robots.txt and /install are unchanged; the pages need a session', async () => {
- assert.match(await (await get('/')).text(), /^OpenVibe\.Bot .* — ok/);
- assert.strictEqual(await (await get('/robots.txt')).text(), 'User-agent: *\nDisallow: /\n');
+ await check('/ is the front page (test/home.test.js); /robots.txt and /install are unchanged; the pages need a session', async () => {
+ assert.match(await (await get('/')).text(), /<h1>Drive your robot/);
+ const robots = await (await get('/robots.txt')).text();
+ assert.match(robots, /^Allow: \/\$$/m, 'the front page is crawlable');
+ for (const p of ['/auth/', '/api/', '/robots', '/pair/', '/panel/', '/install']) assert.ok(robots.includes(`Disallow: ${p}\n`), p);
+ assert.match(robots, /^Sitemap: http:\/\/bot\.test\/sitemap\.xml$/m);
assert.strictEqual((await get('/install')).status, 302);
assert.strictEqual((await get('/install')).status, 302);
for (const p of ['/robots', '/panel/rob_x', '/pair/rob_x']) {
for (const p of ['/robots', '/panel/rob_x', '/pair/rob_x']) {
const r = await get(p);
const r = await get(p);