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)

OP OpenVibers committed 7 Oct 2026 · f7b5bdb18c · 13 files, +245 −29

* Bot: openvibe.bot serves its own front page (Frame + showcase, only what works today) instead of the Sites vision page.

openvibe.bot now serves its own front page in place of the frozen OpenVibe.Sites page, which was a pre-launch vision page ("being built", "design preview") that promised things Bot does not do: ready-made bots that arrive paired, ESP32, ROS 2 and Android phones, webhooks and SDKs, and live video.

- server/web/home.js (new): renderHome() composes the OpenVibe Frame (openvibe-shared/shell: navbar, footer, theme, SEO head with canonical, ai-summary and WebPage JSON-LD) around openvibe-shared/showcase sections. Hero "Drive your robot from any browser." (actions Sign in with OpenVibe -> /robots, or Your robots when signed in; How it works), with the alpha note that live video in the panel waits for OpenRe.Stream to play a WHIP stream back. "Online in three steps" (add, pair with one pasted command, drive), "What the panel does" (controls from the profile with a latency meter; limits, cooldowns, turn budget and the latched e-stop; 30-day command audit; operators, viewers and the queue; the read-only embed on OpenVibe.Live; the REST API under the same gate) and "Robots it drives today" (Adeept 4WD Smart Car in both builds, Cozmo through a nearby computer, the simulated rover). Every claim is from the README Owns and Devices sections. HOME_CSP is the Frame CSP Host uses (scripts and connect to openvibe.network and events.openvibe.network, frame-ancestors none); only / sends it.
- server/web/routes.js: GET / renders it (private, no-cache, Vary: Cookie, since the first action differs by sign-in), replacing the one-line text answer; the readiness probe is /api/ready and nothing else read /.
- server/app.js: /shared is openvibe-shared serve handler (content-hashed Frame scripts and showcase.css).
- deploy/nginx/openvibe.bot.conf: location = / and /shared/ proxy to the app (Sites _shared has no showcase.css; the old index.html was the only Sites page that used /shared/, at the same hashes); legal pages, robots.txt, sitemap.xml and 404 stay on the Sites files.
- test/home.test.js (new): the page, its CSP and cache headers, one h1, the showcase stylesheet served, no ESP32/ROS 2/Android/buy/webhook claims, the signed-in action, the rest of the site keeping its CSP, the vhost shape. test/panel.test.js and test/api.test.js follow the new /.
- package.json / lockfile, README, STATUS.json: openvibe-shared v2.5.0 -> v2.10.1; README describes the front page and the vhost.

After this deploys, OpenVibe.Sites can drop dist/openvibe.bot/index.html.

Tests: ov test test/home.test.js test/panel.test.js test/api.test.js test/embed-panel.test.js test/docs.test.js: 5 passed.

* Bot front page: review follow-ups (index.html, robots.txt, release.json, status.json, copy).

Review follow-ups (DeepSeek cross-family review of this PR):
- vhost: /index.html 301s to / (the replaced Sites page stays on disk until Sites drops it); /robots.txt and /release.json proxy to the app; /status.json (a placeholder file saying "live": false) answers 404; / gets the same limit_req as the other page routes; header comment updated.
- server/web/routes.js: robots.txt allows the front page and disallows sign-in, the API and the signed-in pages, and names the sitemap (it used to disallow everything; the public got the Sites copy).
- server/web/home.js: one Adeept kit (the mecanum build is a profile, not a kit); the embed lets viewers follow the robot state, not watch video; the manifest link the old page had.
- Stale prose about the one-line GET / fixed in server/app.js, server/web/routes.js, server/api/actor-limits.js, docs/protocol.md; README "Does not own" no longer lists the front page.
- Tests: home.test.js asserts Vary and the vhost rules; panel.test.js asserts the new robots.txt.

Tests: ov test test/home.test.js test/panel.test.js test/api.test.js test/docs.test.js: 4 passed.

Changes

ddiff --git a/README.md b/README.md
iindex 965b4b0..a17ca8d 100644
---- a/README.md
++++ b/README.md
@@@ -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.
ddiff --git a/STATUS.json b/STATUS.json
iindex ffb1269..9a02130 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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"
}
ddiff --git a/deploy/nginx/openvibe.bot.conf b/deploy/nginx/openvibe.bot.conf
iindex 5eb976b..daec142 100644
---- a/deploy/nginx/openvibe.bot.conf
++++ b/deploy/nginx/openvibe.bot.conf
@@@ -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;
ddiff --git a/docs/protocol.md b/docs/protocol.md
iindex b0bc6d9..69863f6 100644
---- a/docs/protocol.md
++++ b/docs/protocol.md
@@@ -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
ddiff --git a/package-lock.json b/package-lock.json
iindex 7875412..8caaefa 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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"
ddiff --git a/package.json b/package.json
iindex 048de41..d8dade1 100644
---- a/package.json
++++ b/package.json
@@@ -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"
},
ddiff --git a/server/api/actor-limits.js b/server/api/actor-limits.js
iindex c0ee1e8..897d373 100644
---- a/server/api/actor-limits.js
++++ b/server/api/actor-limits.js
@@@ -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');
ddiff --git a/server/app.js b/server/app.js
iindex 6961a49..a1bef9c 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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,
ddiff --git a/server/web/home.js b/server/web/home.js
nnew file mode 100644
iindex 0000000..7f2b098
---- /dev/null
++++ b/server/web/home.js
@@@ -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 };
ddiff --git a/server/web/routes.js b/server/web/routes.js
iindex 2fd7e13..521b906 100644
---- a/server/web/routes.js
++++ b/server/web/routes.js
@@@ -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, limi
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.post('/robots', ...manage, express.urlencoded({ extended: false, limit: '8kb' }), page(async (req, res) => {
ddiff --git a/test/api.test.js b/test/api.test.js
iindex 9e7f08f..9fd0ed6 100644
---- a/test/api.test.js
++++ b/test/api.test.js
@@@ -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 () => {
ddiff --git a/test/home.test.js b/test/home.test.js
nnew file mode 100644
iindex 0000000..0c0f3df
---- /dev/null
++++ b/test/home.test.js
@@@ -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();
++})();
ddiff --git a/test/panel.test.js b/test/panel.test.js
iindex f29f8bf..22fec82 100644
---- a/test/panel.test.js
++++ b/test/panel.test.js
@@@ -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);