openvibers/openvibe.bot Commits
Bot: a public robot directory (/robots, /models, /r/:id) and the add-a-robot page; owners list a robot from its panel (#90)
Changes
@@@ -15,6 +15,7 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato- The `/device` and `/control` WebSockets, the command gate and the latched e-stop.- The 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 rover lives in a small arena (`public/sim-world.js`, shared by the server and the browser): walls and crates stop it, its telemetry carries a range finder (`distance_cm`), three line sensors over a strip of tape and its pose, and its panel draws the camera from that pose (`public/sim-view.js`) with a map of the arena in the corner.++- The robot directory (`server/web/directory.js`, inside the OpenVibe Frame): `/robots` is public and lists the robots their owners list (online state, model, owner, who may drive), every model Bot drives and, signed in, your own robots; `/models` and `/models/:id` say how each model connects, its setup steps, its guide and the controls its panel gets; `/r/:id` is a listed robot's public page, watching it live through the embeddable panel; `/robots/new` adds one (name, what it is, who may drive). The owner lists a robot from its panel (`POST /robots/:id/listing`, migration 0009): listing turns on watching (`embed_public`), and turning watching off unlists it.- `/try`, public and without an account: the same panel on a rover that runs in the visitor's own browser (`public/try-device.js` answers the panel's `/control` frames with the gate's shaping, deadman and latched e-stop), so anyone can drive before owning hardware. Nothing after the page reaches the server. 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; a tile plays the robot's OpenRestream WebRTC session when one is live (the mediasoup-client viewer flow OpenVibe.Live runs, against the session's playback descriptor), and stays the placeholder when there is none.- The legal pages (`/terms`, `/privacy`, `/dmca`, `openvibe-shared/legal`), `/sitemap.xml`, `/manifest.webmanifest`, `/robots.txt` and the browser-facing 404 (`server/app.js`, `server/web/routes.js`): Bot serves them all itself, and the nginx vhost has no static root.- The public build pages (`server/web/docs.js`): `/docs` (an index), `/docs/drivers` (write a driver for the OpenVibe Node — the plugin protocol, every message, the safety rules and how to test), and `/docs/profiles` (the `bot.robot-profile@1` fields, a full example and a validator). The validator answers `POST /docs/profiles/validate` (a form) and `POST /api/v1/profiles/validate` (JSON in, `{ valid, problems[] }` out) by running the same checks a shipped profile gets (`server/profiles` `checkProfile`); the pages carry no script and run under the site-wide CSP.@@@ -41,8 +41,9 @@"command audit kept 30 days, and the bot.robot.online/offline, bot.estop.set/cleared and bot.command.refused outbox events","public robot-profile catalogue (GET /profiles, /profiles/:id); robots are private to their owner, with public control only as a timed turn queue","per-actor limits on capability-boundary writes (openvibe-sdk/limits; shared through Valkey when VALKEY_URL is set)",-- "signed-in pages: /robots (list and add), /pair/:id (the code and the one-paste installer command) and /panel/:id, rendered from the robot's profile and driven over /control; a sim.rover robot is driven by an in-process simulated device in a small arena shared with the browser (public/sim-world.js: walls and crates stop it; telemetry carries distance_cm, line sensors and its pose, from which the panel draws its camera and a map)",++ "signed-in pages: /robots/new (add), /pair/:id (the code and the one-paste installer command) and /panel/:id, rendered from the robot's profile and driven over /control; a sim.rover robot is driven by an in-process simulated device in a small arena shared with the browser (public/sim-world.js: walls and crates stop it; telemetry carries distance_cm, line sensors and its pose, from which the panel draws its camera and a map)","/try (public, no account): the real panel on a sim.rover that runs in the visitor's browser (public/try-device.js answers /control locally with the gate's value shaping, the deadman and the latched e-stop); listed in the sitemap and llms.txt",++ "the robot directory (Frame pages, server/web/directory.js): public /robots (listed robots with online state, model and owner; every model; a signed-in person's own robots), /models and /models/:id (how it connects, setup steps, guide, panel controls), /r/:id (a listed robot's public page with its live embeddable panel), /robots/new (name, model choice grouped by connection kind, who may drive); owners list from the panel (POST /robots/:id/listing, robots.listed, migration 0009), listing turns on embed_public and unwatching unlists; listed robots and every model are in the sitemap","the panel's People card: the owner adds an operator or viewer by @username (OpenVibe.Network resolves the name to a subject) and removes them again, shown with Network's names; a signed-in visitor on an open-queue robot sees their turn or their place in line and can leave the queue, promoting the next person (POST /robots/:id/operators and …/operators/:subject/remove, POST /robots/:id/queue/leave)","panel input: a touch joystick, the keyboard and a gamepad (Gamepad API), each held control re-sent while held and zeroed at once on release; a latency meter (round-trip median and telemetry age); one camera tile per camera the profile lists, playing the robot's live OpenRestream WebRTC session (the Adeept and Cozmo profiles carry a camera widget since 2026-10-13); a readout shows the sensor keys the Node's drivers report (distance_cm, line, cliff and picked_up); a live online indicator on /pair/:id read from /control","platform.job@1 jobs dispatched to a paired Node over /device (platform.job-frame@1: resent unacked on reconnect, only to an advertised runtime class) and metered per second into run_usage_outbox for billing.usage.record",@@@ -51,5 +52,5 @@"streaming and recording toggles per robot, off by default and owner-only: media records live sessions to OpenVibe.Media (the OpenRestream 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-13"++ "updated": "2026-10-14"}nnew file mode 100644@@@ -0,0 +1,9 @@++-- phase: expand++--++-- The robot directory (plan T15, /robots): an owner may list a robot publicly. A listed robot has a public page++-- (/r/:id) and appears in the directory with its model and online state; anyone may watch it there (read-only, the++-- /watch view), so listing also turns on embed_public. Additive only: off for every existing robot.++++ALTER TABLE robots ADD COLUMN listed boolean NOT NULL DEFAULT false;++ALTER TABLE robots ADD COLUMN listed_at timestamptz;++CREATE INDEX IF NOT EXISTS robots_listed_idx ON robots (listed_at DESC) WHERE listed;nnew file mode 100644@@@ -0,0 +1,81 @@++/* OpenVibe.Bot's directory pages (/robots, /models, /models/:id, /r/:id, /robots/new) inside the OpenVibe Frame.++ Colours come from the Frame's theme variables, so the pages follow the person's theme (light or dark). */++.dir { font-family: var(--ov-font, 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif); max-width: 1120px; margin: 0 auto; padding: 28px 16px 64px; color: var(--text-primary, #e6edf7); font-size: 15.5px; line-height: 1.6; }++.dir a { color: var(--accent-light, #60a5fa); text-underline-offset: 2px; }++.dir .muted { color: var(--text-secondary, #96a7c2); }++.dir code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: .9em; background: var(--bg-secondary, #101828); border: 1px solid var(--border, #1f2d47); border-radius: 5px; padding: 1px 5px; }++.dir-crumb { margin: 0 0 10px; font-size: 14px; color: var(--text-secondary, #96a7c2); }++.dir-hero { margin: 0 0 26px; max-width: 760px; }++.dir-hero h1 { font-size: clamp(28px, 4.2vw, 40px); line-height: 1.12; letter-spacing: -.02em; margin: 0 0 10px; }++.dir-hero p { margin: 0 0 10px; }++.dir-hero > p.muted { font-size: 17px; }++++.dir-sec { margin: 0 0 30px; }++.dir-sec h2 { font-size: 19px; letter-spacing: -.01em; margin: 0 0 12px; }++.dir-sec-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-bottom: 12px; }++.dir-sec-head h2 { margin: 0; }++.dir .card { background: var(--bg-secondary, #101828); border: 1px solid var(--border, #1f2d47); border-radius: 14px; padding: 18px 20px; }++.dir .card h2 { font-size: 13px; font-weight: 750; letter-spacing: .07em; text-transform: uppercase; color: var(--text-secondary, #96a7c2); }++.dir .card ul, .dir .card ol { margin: 0 0 10px; padding-left: 20px; }++.dir .card li { margin: 4px 0; }++.dir-cols { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; margin-bottom: 30px; }++.dir-cols .dir-sec { margin: 0; }++++.dir-actions { display: flex; flex-wrap: wrap; gap: 10px; margin: 14px 0 0; }++.dir-btn { display: inline-flex; align-items: center; min-height: 42px; padding: 0 16px; border-radius: 10px; font-weight: 650; font-size: 14.5px; text-decoration: none; border: 1px solid var(--border, #1f2d47); background: var(--bg-secondary, #101828); color: var(--text-primary, #e6edf7) !important; cursor: pointer; font-family: inherit; }++.dir-btn:hover { border-color: var(--accent, #3b82f6); }++.dir-btn.dir-primary { background: var(--accent-strong, var(--accent, #3472d8)); border-color: transparent; color: var(--on-accent-strong, #fff) !important; }++.dir-btn.dir-primary:hover { filter: brightness(1.08); }++++.dir-grid { list-style: none; margin: 0; padding: 0; display: grid; gap: 14px; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); }++.dir-card { background: var(--bg-secondary, #101828); border: 1px solid var(--border, #1f2d47); border-radius: 14px; display: flex; flex-direction: column; transition: border-color .15s; }++.dir-card:hover { border-color: var(--accent, #3b82f6); }++.dir-card-link { display: grid; gap: 6px; padding: 16px 18px 10px; color: inherit !important; text-decoration: none; }++.dir-card-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; }++.dir-card-head b { font-size: 16px; }++.dir-card-text { color: var(--text-secondary, #96a7c2); font-size: 14px; }++.dir-card-foot { margin: auto 0 0; padding: 0 18px 14px; font-size: 13px; color: var(--text-secondary, #96a7c2); }++.dir-chips { list-style: none; display: flex; flex-wrap: wrap; gap: 6px; margin: 0; padding: 0 18px 16px; }++.dir .card .dir-chips { padding: 0; }++.dir-chips li { font-size: 12.5px; padding: 2px 9px; border-radius: 999px; border: 1px solid var(--border, #1f2d47); color: var(--text-secondary, #96a7c2); margin: 0; }++++.dir-badge { flex: none; font-size: 11.5px; font-weight: 700; letter-spacing: .03em; padding: 2px 8px; border-radius: 999px; border: 1px solid currentColor; }++.dir-browser { color: #a78bfa; } .dir-onboard { color: #22c55e; } .dir-bridge { color: #f59e0b; } .dir-server { color: #38bdf8; }++.dir-pill { display: inline-block; font-size: 12.5px; padding: 2px 10px; border-radius: 999px; border: 1px solid var(--border, #1f2d47); text-decoration: none; color: var(--text-primary, #e6edf7) !important; }++.dir-pill.dir-quiet { color: var(--text-secondary, #96a7c2) !important; }++.dir-state { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; font-weight: 650; color: var(--text-secondary, #96a7c2); }++.dir-dot { width: 8px; height: 8px; border-radius: 50%; background: #64748b; }++.dir-state[data-state="online"] { color: #22c55e; }++.dir-state[data-state="online"] .dir-dot { background: #22c55e; box-shadow: 0 0 0 3px rgba(34, 197, 94, .2); }++++.dir-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }++.dir-row { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 14px; padding: 12px 16px; border: 1px solid var(--border, #1f2d47); border-radius: 12px; background: var(--bg-secondary, #101828); }++.dir-row > a b { font-size: 15.5px; }++.dir-row-actions { margin-left: auto; font-size: 14px; }++.dir-cta { background: var(--bg-secondary, #101828); border: 1px solid var(--border, #1f2d47); border-radius: 16px; padding: 20px 22px; }++++.dir-live { border: 1px solid var(--border, #1f2d47); border-radius: 16px; overflow: hidden; background: #0b0f15; }++.dir-live iframe { display: block; width: 100%; height: min(78vh, 760px); border: 0; }++.dir-note { font-size: 13.5px; margin-top: 10px; }++++.dir-form { display: grid; gap: 18px; max-width: 860px; }++.dir-field { display: grid; gap: 6px; max-width: 420px; }++.dir-field-label { font-size: 13px; font-weight: 750; letter-spacing: .05em; text-transform: uppercase; color: var(--text-secondary, #96a7c2); }++.dir-form input[type="text"], .dir-form select { font: inherit; min-height: 44px; padding: 0 12px; border-radius: 10px; border: 1px solid var(--border, #1f2d47); background: var(--bg-primary, #0a0f1c); color: var(--text-primary, #e6edf7); }++.dir-form input[type="text"]:focus, .dir-form select:focus { outline: 2px solid var(--accent, #3b82f6); outline-offset: 1px; }++.dir-fieldset { border: 0; margin: 0; padding: 0; display: grid; gap: 8px; }++.dir-group { margin: 10px 0 2px; font-size: 14px; font-weight: 650; }++.dir-choices { display: grid; gap: 10px; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); }++.dir-choice { position: relative; display: flex; gap: 10px; align-items: flex-start; padding: 14px 16px; border: 1px solid var(--border, #1f2d47); border-radius: 12px; background: var(--bg-secondary, #101828); cursor: pointer; }++.dir-choice:hover { border-color: var(--accent, #3b82f6); }++.dir-choice:has(input:checked) { border-color: var(--accent, #3b82f6); box-shadow: 0 0 0 1px var(--accent, #3b82f6) inset; }++.dir-choice input { margin-top: 4px; accent-color: var(--accent, #3b82f6); }++.dir-choice-body { display: grid; gap: 4px; }++.dir-error { border: 1px solid rgba(239, 68, 68, .45); background: rgba(239, 68, 68, .08); border-radius: 10px; padding: 12px 14px; margin: 0; }++++@media (max-width: 760px) {++ .dir { padding-top: 18px; }++ .dir-cols { grid-template-columns: 1fr; }++ .dir-row-actions { margin-left: 0; width: 100%; }++ .dir-live iframe { height: 86vh; }++}@@@ -99,7 +99,9 @@ function createApp(opts = {}) {app.use((req, res, next) => {res.setHeader('X-Content-Type-Options', 'nosniff');res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');-- res.setHeader('Content-Security-Policy', ["default-src 'self'", "frame-ancestors 'self'", "object-src 'none'", "base-uri 'self'"].join('; '));++ // Cloudflare's edge adds its Web Analytics beacon to HTML pages: its script and its report are the only outside sources.++ res.setHeader('Content-Security-Policy', ["default-src 'self'", "script-src 'self' https://static.cloudflareinsights.com", "connect-src 'self' https://cloudflareinsights.com",++ "frame-ancestors 'self'", "object-src 'none'", "base-uri 'self'"].join('; '));next();});app.use(cookieParser());@@@ -240,9 +240,24 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =}/** Opt a robot in or out of anonymous read-only embedding (the owner check is the caller's; no event). */async function setEmbedPublic(id, value) {-- await db.query('UPDATE robots SET embed_public = $2, updated_at = $3 WHERE id = $1', [id, !!value, iso(now())]);++ // Unlisting is part of stopping public watching: a listed robot is always watchable.++ await db.query(`UPDATE robots SET embed_public = $2, updated_at = $3${value ? '' : ', listed = false, listed_at = NULL'} WHERE id = $1`, [id, !!value, iso(now())]);return getRobot(id);}++ /**++ * List a robot in the public directory (/robots, its page /r/:id) or take it out. Listing makes it watchable by++ * anyone (embed_public), since a directory entry nobody can watch is no entry; unlisting leaves embedding as it was.++ * The owner check is the caller's.++ */++ async function setListed(id, value) {++ if (value) await db.query('UPDATE robots SET listed = true, listed_at = COALESCE(listed_at, $2), embed_public = true, updated_at = $2 WHERE id = $1', [id, iso(now())]);++ else await db.query('UPDATE robots SET listed = false, listed_at = NULL, updated_at = $2 WHERE id = $1', [id, iso(now())]);++ return getRobot(id);++ }++ /** The listed robots, newest listing first (the directory). */++ async function listListed({ limit = 60 } = {}) {++ return db.many('SELECT * FROM robots WHERE listed ORDER BY listed_at DESC, id LIMIT $1', [Math.max(1, Math.min(200, limit))]);++ }/*** Remove a robot. Its OpenRestream stream's key is revoked first (rotated with no grace, its sessions ended), so* nothing publishes as the robot afterwards; an OpenRestream that refuses or does not answer leaves the robot in@@@ -998,7 +1013,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =return {db, now, config, log, outbox, link,present: { robot: presentRobot, device: presentDevice, video: presentVideo },-- robots: { create: createRobot, list: listRobots, get: getRobot, update: updateRobot, setEmbedPublic, remove: removeRobot,++ robots: { create: createRobot, list: listRobots, get: getRobot, update: updateRobot, setEmbedPublic, setListed, listListed, remove: removeRobot,localProfile, saveLocalProfile, useCatalogueProfile },members: { roleOf, add: addOperator, remove: removeOperator, list: listOperators },pairing: { create: createPairingCode, redeem, prune: prunePairingCodes, installerCommand, driverForProfile, whipUrl },nnew file mode 100644@@@ -0,0 +1,210 @@++'use strict';++++/**++ * The robot directory (plan T15 "a robot directory (/robots)"), inside the OpenVibe Frame like the front page:++ *++ * renderDirectory({ config, listed, mine, models, signedIn }) GET /robots: robots people list, the models Bot++ * drives and, signed in, your own robots++ * renderModels({ config, models }) GET /models++ * renderModel({ config, model, kit }) GET /models/:id: what it is, how it connects, the++ * panel it gets, its setup steps and guide++ * renderRobotPage({ config, robot, model, owner, state, role }) GET /r/:id: a listed robot's public page (its live++ * panel, watch-only, in a frame of this site)++ * renderAddRobot({ config, models, values, error }) GET /robots/new: name it, choose what it is, who drives++ *++ * Plain markup, no script (the Frame's own aside), every value escaped. A model is a catalogue profile; MODELS adds++ * what a profile does not say (how it connects in words, the setup steps, the guide). An unknown profile still gets++ * a page from its own fields.++ */++const { esc } = require('./render');++const { framePage } = require('./home');++++const NODE_BLOB = 'https://github.com/OpenVibers/OpenVibe.Node/blob/main';++const CONNECT = {++ browser: { label: 'Simulated', text: 'Runs inside Bot and in your browser: nothing to buy or install.' },++ onboard: { label: 'On-board', text: 'The OpenVibe Node runs on the robot\'s own computer and drives its hardware.' },++ bridge: { label: 'Bridge', text: 'The OpenVibe Node runs on a computer or phone next to the robot and joins the robot\'s own link. Nothing on the robot changes.' },++ server: { label: 'Server-side', text: 'Nothing to install: Bot connects to the device itself.' },++};++const MODELS = {++ 'sim.rover': {++ connect: 'browser', order: 0,++ setup: ['Add it and its panel drives straight away; the simulator runs inside Bot.', 'Or drive one at /try without an account.'],++ guide: { href: '/try', label: 'Try it now, no account' },++ },++ 'adeept.adr036': {++ connect: 'onboard', order: 1,++ setup: ['Build the kit and flash Raspberry Pi OS (64-bit, Lite is enough) on its card.', 'Add the robot here and paste the one pairing command on the Pi.',++ 'The OpenVibe Node drives the Robot HAT itself, publishes the Pi camera, and stops the motors the moment commands stop. The kit\'s stock control server is never run.'],++ guide: { href: `${NODE_BLOB}/docs/hardware-adeept.md`, label: 'Adeept setup guide' },++ },++ 'adeept.adr036.mecanum': {++ connect: 'onboard', order: 2,++ setup: ['The same kit built with mecanum wheels, so it also moves sideways.', 'Pair it with the same one command; the installer picks the mecanum driver.'],++ guide: { href: `${NODE_BLOB}/docs/hardware-adeept.md`, label: 'Adeept setup guide' },++ },++ cozmo: {++ connect: 'bridge', order: 3,++ setup: ['Cozmo keeps its own firmware and app; nothing on it changes.', 'On a computer beside it (one network adapter on Cozmo\'s Wi-Fi, one on the internet) paste the pairing command.',++ 'The bridge drives it through PyCozmo and streams its camera; cliff and pick-up stop it on the robot.'],++ guide: { href: `${NODE_BLOB}/docs/hardware-cozmo.md`, label: 'Cozmo setup guide' },++ },++ looi: {++ connect: 'bridge', order: 4,++ setup: ['The community\'s Generic LOOI driver bridges to the desk robot over Bluetooth from a Windows PC.', 'Install the Node with --driver looi; the phone in LOOI keeps doing what it does.'],++ guide: { href: `${NODE_BLOB}/docs/hardware-looi.md`, label: 'LOOI driver contract' },++ },++ 'camera.onvif': {++ connect: 'server', order: 5,++ setup: ['Any ONVIF pan-tilt-zoom camera: Bot moves it, with the same gate and limits as a robot.', 'Today an operator names the camera in Bot\'s configuration (its address and the names of its secrets).'],++ guide: { href: 'https://github.com/OpenVibers/OpenVibe.Bot#readme', label: 'ONVIF notes' },++ },++ 'relay.generic': {++ connect: 'onboard', order: 6,++ setup: ['Any Raspberry Pi or Linux machine running the OpenVibe Node.', 'Each button on the panel is passed to a script of yours by the relay driver: lights, a door, a motor board.'],++ guide: { href: '/docs/drivers', label: 'Write a driver' },++ },++};++const CAPABILITY_WORDS = {++ 'drive.differential': 'Drives', 'drive.mecanum': 'Drives any direction', 'servo.pan_tilt': 'Pan / tilt camera', head: 'Head', lift: 'Lift',++ 'lights.rgb': 'RGB lights', 'lights.backpack': 'Backpack lights', 'lights.cube': 'Cube lights', 'speaker.horn': 'Horn', 'speaker.say': 'Speaks',++ 'display.text': 'Face and text', 'sensor.ultrasonic': 'Range finder', 'sensor.line': 'Line tracking', 'sensor.cliff': 'Cliff sensors',++ 'sensor.pickup': 'Pick-up sensor', battery: 'Battery', ptz: 'Pan, tilt, zoom', camera: 'Live camera', relay: 'Your own buttons',++};++const POLICY_WORDS = { private: 'Only its owner and the people they add drive it.', invite: 'People the owner invites drive it.', queue: 'Anyone signed in can take a timed turn.' };++++/** A catalogue profile as a model: its own fields plus MODELS. */++function modelOf(profile) {++ const extra = MODELS[profile.id] || {};++ const connect = extra.connect || (profile.kind === 'bridge' ? 'bridge' : profile.kind === 'server' ? 'server' : 'onboard');++ return {++ id: profile.id, name: profile.name, vendor: profile.vendor || '', description: profile.description || '',++ connect, order: extra.order != null ? extra.order : 100, setup: extra.setup || [], guide: extra.guide || null,++ capabilities: (profile.capabilities || []).filter((c) => CAPABILITY_WORDS[c]), widgets: profile.widgets || [], profile,++ };++}++const sortModels = (models) => models.slice().sort((a, b) => a.order - b.order || a.name.localeCompare(b.name));++++const chips = (caps) => `<ul class="dir-chips">${caps.map((c) => `<li>${esc(CAPABILITY_WORDS[c])}</li>`).join('')}</ul>`;++const badge = (connect) => `<span class="dir-badge dir-${esc(connect)}">${esc(CONNECT[connect].label)}</span>`;++const link = (href, text, cls = '') => `<a${cls ? ` class="${cls}"` : ''} href="${esc(href)}"${/^https?:/.test(href) ? ' rel="noopener"' : ''}>${esc(text)}</a>`;++/** A card's line: the description's first clause, cut at a word near `max` characters. */++function short(text, max = 120) {++ const first = String(text).split(/(?<=[.:;])\s/)[0].replace(/[:;]$/, '.');++ if (first.length <= max) return /[.!?]$/.test(first) ? first : `${first}.`;++ return `${first.slice(0, first.lastIndexOf(' ', max)).replace(/[,(]$/, '')}…`;++}++const ownerName = (o) => (o && o.username ? `@${o.username}` : 'an OpenVibe member');++++function shell({ config, path, title, description, body }) {++ return framePage({ config, title: `${title} · OpenVibe.Bot`, description, canonicalPath: path, head: '<link rel="stylesheet" href="/directory.css">', body: `<main id="main" class="dir">${body}</main>` });++}++++function modelCard(m) {++ return `<li class="dir-card"><a class="dir-card-link" href="/models/${esc(m.id)}"><span class="dir-card-head"><b>${esc(m.name)}</b>${badge(m.connect)}</span>`++ + `<span class="dir-card-text">${esc(short(m.description))}</span></a>${chips(m.capabilities.slice(0, 6))}</li>`;++}++++function robotCard(r) {++ return `<li class="dir-card"><a class="dir-card-link" href="/r/${esc(r.id)}"><span class="dir-card-head"><b>${esc(r.name)}</b>`++ + `<span class="dir-state" data-state="${r.online ? 'online' : 'offline'}"><span class="dir-dot"></span>${r.online ? 'Online' : 'Offline'}</span></span>`++ + `<span class="dir-card-text">${esc(r.model_name)} · ${esc(ownerName(r.owner))}</span></a>`++ + `<p class="dir-card-foot">${esc(POLICY_WORDS[r.access_policy] || '')}</p></li>`;++}++++// ── GET /robots ─────────────────────────────────────────────────────────────────────────────────────++function renderDirectory({ config, listed = [], mine = null, models = [], signedIn = false }) {++ const yours = !signedIn ? `<section class="dir-sec dir-cta"><h2>Have a robot?</h2>++<p class="muted">Sign in to add it and pair it with one pasted command, or drive a simulated rover first, no account needed.</p>++<p class="dir-actions">${link('/try', 'Try the panel', 'dir-btn dir-primary')} ${link('/auth/login?next=%2Frobots%2Fnew', 'Sign in and add a robot', 'dir-btn')}</p></section>`++ : `<section class="dir-sec" aria-labelledby="yours-h"><div class="dir-sec-head"><h2 id="yours-h">Your robots</h2>${link('/robots/new', 'Add a robot', 'dir-btn dir-primary')}</div>++${mine && mine.length ? `<ul class="dir-list">${mine.map((r) => `<li class="dir-row"><a href="/panel/${esc(r.id)}"><b>${esc(r.name)}</b></a><span class="muted">${esc(r.model_name)}</span>`++ + `${r.listed ? `<a class="dir-pill" href="/r/${esc(r.id)}">Listed</a>` : '<span class="dir-pill dir-quiet">Not listed</span>'}`++ + `<span class="dir-row-actions">${link(`/panel/${r.id}`, 'Panel')} · ${link(`/pair/${r.id}`, 'Pair a device')}</span></li>`).join('')}</ul>`++ : '<p class="muted">No robots yet. Add one: a simulated rover drives straight away, before any hardware exists.</p>'}</section>`;++ const body = `<header class="dir-hero"><h1>Robots</h1>++<p class="muted">Robots people share on OpenVibe.Bot, and every model it drives. Watch any listed robot live; some take visitors at the controls in turns.</p></header>++${yours}++<section class="dir-sec" aria-labelledby="listed-h"><h2 id="listed-h">In the directory</h2>++${listed.length ? `<ul class="dir-grid">${listed.map(robotCard).join('')}</ul>`++ : `<p class="muted">No robot is listed yet. An owner lists theirs from its panel (Directory), and it shows here with its live camera.</p>`}</section>++<section class="dir-sec" aria-labelledby="models-h"><div class="dir-sec-head"><h2 id="models-h">Models it drives</h2>${link('/models', 'All models')}</div>++<ul class="dir-grid">${sortModels(models).map(modelCard).join('')}</ul></section>++<section class="dir-sec" aria-labelledby="byo-h"><h2 id="byo-h">Something else?</h2>++<p class="muted">Any robot with a computer on it or next to it can join: a Raspberry Pi or other Linux board, an ESP32, a ROS 2 robot, an ONVIF camera. Describe it with a profile and the panel draws itself; write a driver in any language for the hardware.</p>++<p class="dir-actions">${link('/docs/profiles', 'Robot profiles', 'dir-btn')} ${link('/docs/drivers', 'Write a driver', 'dir-btn')}</p></section>`;++ return shell({ config, path: '/robots', title: 'Robots', description: 'Robots on OpenVibe.Bot: watch the ones people list live, see every model it drives, and add your own.', body });++}++++// ── GET /models ─────────────────────────────────────────────────────────────────────────────────────++function renderModels({ config, models = [] }) {++ const groups = ['browser', 'onboard', 'bridge', 'server'].map((k) => [k, sortModels(models).filter((m) => m.connect === k)]).filter(([, ms]) => ms.length);++ const body = `<header class="dir-hero"><h1>Models</h1>++<p class="muted">Every robot OpenVibe.Bot has a profile for. A profile lays out the panel, so each of these drives with the controls it really has.</p></header>++${groups.map(([k, ms]) => `<section class="dir-sec" aria-labelledby="g-${esc(k)}"><h2 id="g-${esc(k)}">${esc(CONNECT[k].label)}</h2><p class="muted">${esc(CONNECT[k].text)}</p>++<ul class="dir-grid">${ms.map(modelCard).join('')}</ul></section>`).join('\n')}++<section class="dir-sec"><h2>Not here?</h2><p class="muted">Write a profile for it and check it with the validator; a driver for its hardware runs under the OpenVibe Node.</p>++<p class="dir-actions">${link('/docs/profiles', 'Robot profiles', 'dir-btn')} ${link('/docs/drivers', 'Write a driver', 'dir-btn')}</p></section>`;++ return shell({ config, path: '/models', title: 'Models', description: 'The robots OpenVibe.Bot drives: kits, bridged robots like Cozmo and LOOI, ONVIF cameras and a simulated rover.', body });++}++++// ── GET /models/:id ─────────────────────────────────────────────────────────────────────────────────++function renderModel({ config, model, kit = null }) {++ const controls = model.widgets.filter((w) => w.type !== 'latency').map((w) => `<li>${esc(w.label || w.type)}</li>`).join('');++ const parts = kit ? `<section class="dir-sec card"><h2>What you need</h2><ul>${kit.parts.map((p) => `<li>${esc(p.qty)}× ${esc(p.name)}${p.note ? ` <span class="muted">(${esc(p.note)})</span>` : ''}</li>`).join('')}</ul>++${kit.build_guide_url ? `<p>${link(kit.build_guide_url, 'The kit\'s build guide')}</p>` : ''}</section>` : '';++ const add = model.connect === 'browser'++ ? `${link('/try', 'Try it now, no account', 'dir-btn dir-primary')} ${link(`/robots/new?profile=${encodeURIComponent(model.id)}`, 'Add one to your robots', 'dir-btn')}`++ : `${link(`/robots/new?profile=${encodeURIComponent(model.id)}`, 'Add this robot', 'dir-btn dir-primary')}${model.guide ? ` ${link(model.guide.href, model.guide.label, 'dir-btn')}` : ''}`;++ const body = `<p class="dir-crumb">${link('/robots', 'Robots')} / ${link('/models', 'Models')}</p>++<header class="dir-hero"><h1>${esc(model.name)}</h1><p>${badge(model.connect)}${model.vendor ? ` <span class="muted">by ${esc(model.vendor)}</span>` : ''}</p>++<p class="muted">${esc(model.description)}</p><p class="dir-actions">${add}</p></header>++<div class="dir-cols">++<section class="dir-sec card"><h2>How it connects</h2><p><b>${esc(CONNECT[model.connect].label)}.</b> ${esc(CONNECT[model.connect].text)}</p>++${model.setup.length ? `<ol>${model.setup.map((s) => `<li>${esc(s)}</li>`).join('')}</ol>` : ''}</section>++<section class="dir-sec card"><h2>Its panel</h2><p class="muted">Laid out from its profile (<code>${esc(model.id)}</code>):</p><ul>${controls}</ul>${chips(model.capabilities)}</section>++</div>++${parts}++<section class="dir-sec card"><h2>Safety</h2><p class="muted">Every command passes Bot's gate: roles, the owner's limits and cooldowns, and a latched e-stop only the owner clears. The device stops the motors by itself when commands stop arriving, without asking the network.</p></section>`;++ return shell({ config, path: `/models/${model.id}`, title: model.name, description: `${model.name} on OpenVibe.Bot: how it connects, the panel it gets and how to set it up.`, body });++}++++// ── GET /r/:id ──────────────────────────────────────────────────────────────────────────────────────++function renderRobotPage({ config, robot, model, owner = null, state = null, role = null }) {++ const online = !!(state && state.online);++ const turn = robot.access_policy === 'queue' ? link(`/panel/${robot.id}`, 'Take a turn at the controls', 'dir-btn dir-primary') : '';++ const member = role ? link(`/panel/${robot.id}`, role === 'owner' ? 'Open your panel' : 'Open the panel', 'dir-btn dir-primary') : '';++ const body = `<p class="dir-crumb">${link('/robots', 'Robots')}</p>++<header class="dir-hero"><h1>${esc(robot.name)}</h1>++<p><span class="dir-state" data-state="${online ? 'online' : 'offline'}"><span class="dir-dot"></span>${online ? 'Online' : 'Offline'}</span> ${link(`/models/${model.id}`, model.name, 'dir-pill')} <span class="muted">by ${esc(ownerName(owner))}</span></p>++<p class="muted">${esc(POLICY_WORDS[robot.access_policy] || '')}</p>++${member || turn ? `<p class="dir-actions">${member || turn}</p>` : ''}</header>++<div class="dir-live"><iframe src="/panel/${esc(robot.id)}/embed" title="${esc(robot.name)}: live panel" loading="lazy" allow="autoplay; gamepad"></iframe></div>++<p class="muted dir-note">Watching is open to everyone; driving follows the owner's rules and passes the same gate and e-stop as on its own panel.</p>`;++ return shell({ config, path: `/r/${robot.id}`, title: robot.name, description: `${robot.name}, a ${model.name} on OpenVibe.Bot. ${POLICY_WORDS[robot.access_policy] || ''}`.trim(), body });++}++++// ── GET /robots/new ─────────────────────────────────────────────────────────────────────────────────++const POLICIES = [['private', 'Only me and people I add'], ['invite', 'People I invite'], ['queue', 'Anyone signed in, taking turns']];++function renderAddRobot({ config, models = [], values = {}, error = null }) {++ const chosen = values.profile_id || 'sim.rover';++ const option = (m) => `<label class="dir-choice"><input type="radio" name="profile_id" value="${esc(m.id)}"${m.id === chosen ? ' checked' : ''} required>`++ + `<span class="dir-choice-body"><span class="dir-card-head"><b>${esc(m.name)}</b>${badge(m.connect)}</span><span class="dir-card-text">${esc(CONNECT[m.connect].text)}</span></span></label>`;++ const groups = [['browser', 'Try it with no hardware'], ['onboard', 'A robot with its own computer'], ['bridge', 'A robot you connect through a computer next to it'], ['server', 'A camera Bot connects to']]++ .map(([k, title]) => [title, sortModels(models).filter((m) => m.connect === k)]).filter(([, ms]) => ms.length);++ const body = `<p class="dir-crumb">${link('/robots', 'Robots')}</p>++<header class="dir-hero"><h1>Add a robot</h1><p class="muted">Name it, say what it is, and choose who may drive. A simulated rover drives the moment it is added; anything else gets a one-time pairing code and one command to paste on its computer.</p></header>++<form class="dir-form" method="post" action="/robots">++${error ? `<p class="dir-error" role="alert">${esc(error)}</p>` : ''}++<label class="dir-field"><span class="dir-field-label">Name</span><input type="text" name="name" maxlength="80" required autocomplete="off" placeholder="e.g. Kitchen rover" value="${esc(values.name || '')}"></label>++<fieldset class="dir-fieldset"><legend class="dir-field-label">What are you connecting?</legend>++${groups.map(([title, ms]) => `<p class="dir-group">${esc(title)}</p><div class="dir-choices">${ms.map(option).join('')}</div>`).join('\n')}++<p class="muted dir-note">Something else (an ESP32, a ROS 2 robot, your own board)? Choose the relay robot and drive it with your own script, or ${link('/docs/profiles', 'write a profile')} for it.</p>++</fieldset>++<label class="dir-field"><span class="dir-field-label">Who may drive</span><select name="access_policy">${POLICIES.map(([v, l]) => `<option value="${esc(v)}"${v === (values.access_policy || 'private') ? ' selected' : ''}>${esc(l)}</option>`).join('')}</select></label>++<p><button type="submit" class="dir-btn dir-primary">Add robot</button></p>++</form>`;++ return shell({ config, path: '/robots/new', title: 'Add a robot', description: 'Add a robot to OpenVibe.Bot.', body });++}++++module.exports = { renderDirectory, renderModels, renderModel, renderRobotPage, renderAddRobot, modelOf, sortModels, MODELS, CONNECT, CAPABILITY_WORDS };@@@ -26,12 +26,12 @@ const DESCRIPTION = 'An open control panel for robots: pair a robot with one pasconst 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",++ "script-src 'self' 'unsafe-inline' https://openvibe.network https://static.cloudflareinsights.com","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://openvibe.events",++ "connect-src 'self' https://openvibe.network https://openvibe.events https://cloudflareinsights.com","frame-src 'self' https://openvibe.network","frame-ancestors 'none'","object-src 'none'",@@@ -252,6 +252,15 @@ function renderWidget(w, { profile = {}, allowed_commands = [], signaling_url =const toggle = (name, on, title, note, disabled = false, was = false) => `${was ? `<input type="hidden" name="${esc(name)}_was" value="${on ? 'on' : 'off'}">` : ''}<input type="hidden" name="${esc(name)}" value="off"><label class="toggle"><input type="checkbox" name="${esc(name)}" value="on"${on ? ' checked' : ''}${disabled ? ' disabled' : ''}><span><b>${esc(title)}</b><small>${esc(note)}</small></span></label>`;++/** The owner's directory switch: list the robot on /robots with a public page (/r/:id), watchable by anyone. */++function listingForm(robot) {++ return `<section class="setting-card" aria-labelledby="listing-h"><h2 id="listing-h">Directory</h2>++<form class="embed-form" method="post" action="/robots/${esc(robot.id)}/listing">++${toggle('listed', robot.listed, 'List it in the robot directory', 'It gets a public page where anyone can watch it live (never drive it, unless your queue lets them take a turn). Listing also turns on watching where it is embedded.')}++<button type="submit">Save</button>++</form>${robot.listed ? `<p class="setting-note"><a href="/r/${esc(robot.id)}">Its public page</a></p>` : ''}</section>`;++}++/** The owner's embed switch: plain markup, works without JavaScript. On the panel it is a card; in the robots list, a compact row. */function embedForm(robot, { card = true } = {}) {const form = `<form class="embed-form${card ? '' : ' embed-inline'}" method="post" action="/robots/${esc(robot.id)}/embed" data-embed-form>@@@ -390,7 +399,7 @@ ${embed && role === 'watcher' && !signedIn ? `<p class="embed-sign-in" data-driv<div class="widgets">${widgets.join('\n')}</div>--${tryIt ? tryOutro(profile) : ''}${role === 'owner' && !embed && !tryIt ? `<div class="owner-settings">${localProfileForm(robot, profile, profileForm)}${embedForm(robot)}${streamingForm(robot, streaming)}${people ? peopleCard(robot, people) : ''}</div>` : ''}++${tryIt ? tryOutro(profile) : ''}${role === 'owner' && !embed && !tryIt ? `<div class="owner-settings">${localProfileForm(robot, profile, profileForm)}${listingForm(robot)}${embedForm(robot)}${streamingForm(robot, streaming)}${people ? peopleCard(robot, people) : ''}</div>` : ''}</main>`;return page(tryIt ? 'Try the panel with a simulated rover' : robot.name, body, {scripts: panelScripts(profile, tryIt), bodyClass: embed ? 'embed-page' : 'panel-page',@@@ -416,7 +425,7 @@ function tryOutro(profile) {return `<section class="try-next card" aria-labelledby="try-next-h"><h2 id="try-next-h">Ready for a real one?</h2><p>Add a robot, pair its computer with one pasted command, and this panel drives it. A Raspberry Pi rover, a Cozmo through a nearby computer, an ONVIF camera or a phone are all a few minutes away.</p>--<p class="try-actions"><a class="button primary" href="/robots#add-robot">Add a robot</a> <a class="button quiet" href="/docs/profiles">How a profile draws this panel</a> <a class="button quiet" href="/docs/drivers">Write a driver</a></p>++<p class="try-actions"><a class="button primary" href="/robots/new">Add a robot</a> <a class="button quiet" href="/models">See the models</a> <a class="button quiet" href="/docs/profiles">How a profile draws this panel</a> <a class="button quiet" href="/docs/drivers">Write a driver</a></p></section><script type="application/json" id="try-profile">${JSON.stringify(profile).replace(/</g, '\\u003c')}</script>`;}@@@ -458,7 +467,7 @@ function kitsSection(kits = [], profiles = []) {return `<li class="kit card"><div class="kit-head">${icon('robot', 'icon kit-icon')}<div><h3>${esc(k.name)}</h3>${k.vendor ? `<span class="pill">${esc(k.vendor)}</span>` : ''}</div></div><p class="kit-desc">${esc(k.description || '')}</p><details class="kit-parts"><summary>What you need <span class="muted">(${esc(need)} ${need === 1 ? 'part' : 'parts'})</span></summary><ul>${k.parts.map(part).join('')}</ul></details>--<p class="kit-actions"><a class="button primary" href="/robots?profile=${encodeURIComponent(k.profile_id)}#add-robot">Add this robot</a> ${outLink(k.build_guide_url, 'Build guide', 'button quiet')}</p></li>`;++<p class="kit-actions"><a class="button primary" href="/robots/new?profile=${encodeURIComponent(k.profile_id)}">Add this robot</a> ${outLink(k.build_guide_url, 'Build guide', 'button quiet')}</p></li>`;};return `<section class="kits" aria-labelledby="kits-h"><h2 id="kits-h">Get a robot</h2><p class="muted">Curated kits with a ready-made profile: build one, pair it, and its panel already knows every motor and sensor.</p>@@@ -36,10 +36,11 @@ const { getProfile, listProfiles, loadProfiles } = require('../profiles');const { frameAncestors } = require('../config');const { BotError } = require('../util');const kits = require('../kits');--const { renderPanel, renderEmbedRefused, renderDrivePage, renderRobotsPage, renderPairingPage } = require('./render');++const { renderPanel, renderEmbedRefused, renderDrivePage, renderPairingPage } = require('./render');const { renderDocsIndex, renderDriversPage, renderProfilesPage } = require('./docs');const { checkProfile } = require('../profiles');const { renderHome, HOME_CSP } = require('./home');++const { renderDirectory, renderModels, renderModel, renderRobotPage, renderAddRobot, modelOf } = require('./directory');const PUBLIC = path.join(__dirname, '..', '..', 'public');const HOLD_RESEND_MS = 150;@@@ -49,7 +50,7 @@ const TRY_PROFILE = loadProfiles().get('sim.rover');const SITE_TAGLINE = 'An open control panel for robots';// Every public GET page, for the sitemap. The interactive pages (/robots, /pair/, /panel/) need a session and// are Disallowed in robots.txt; the API and sign-in are not pages.--const PUBLIC_PAGES = ['/', '/try', '/install', '/docs', '/docs/drivers', '/docs/profiles', '/terms', '/privacy', '/dmca'];++const PUBLIC_PAGES = ['/', '/try', '/robots', '/models', '/install', '/docs', '/docs/drivers', '/docs/profiles', '/terms', '/privacy', '/dmca'];// The public pages an AI crawler can read, for /llms.txt and /llms-full.txt (llmstxt.org). Same pages as the// sitemap minus /install (a 302 to the installer script, not a page); no signed-in or per-robot page, nothing// under the API or /internal. `note` is the one-line llms.txt entry, `text` the page's full text in llms-full.txt,@@@ -68,6 +69,16 @@ const LLMS_PAGES = [note: 'Drive a simulated rover in the browser, no account and no hardware.',text: 'A rover simulated in the visitor\'s own browser, driven through the same OpenVibe.Bot panel a paired robot gets: a touch joystick, the keyboard (W A S D or the arrows, Space to stop) or a gamepad, with the same command shaping, deadman and latched e-stop. It has a front camera drawn from its position in a small arena, an ultrasonic distance sensor, three line sensors over a strip of yellow tape, a battery, RGB headlights and a horn. Nothing pressed there moves real hardware, and nothing needs an account. The page ends with the way to a real robot: add one, pair its computer with one pasted command, and the same panel drives it.',},++ {++ path: '/robots', title: 'Robots',++ note: 'The robot directory: robots people list, watchable live, and the models Bot drives.',++ text: 'The OpenVibe.Bot robot directory. Owners may list a robot; a listed robot has a public page where anyone can watch it live through its read-only panel, and a robot whose owner opened a queue lets signed-in visitors take timed turns at the controls. The page also lists every model Bot has a profile for, and, for a signed-in person, their own robots with the way to add one.',++ },++ {++ path: '/models', title: 'Models',++ note: 'Every robot model Bot drives, by how it connects.',++ text: 'The robots OpenVibe.Bot drives, grouped by how they connect: simulated (the rover that runs in Bot and in the browser), on-board (the OpenVibe Node on the robot\'s own computer: the Adeept ADR036 4WD car and its mecanum build, the relay robot for any Linux board), bridge (the Node on a computer or phone next to the robot: Cozmo through PyCozmo, the LOOI desk robot over Bluetooth) and server-side (ONVIF pan-tilt-zoom cameras, nothing to install). Each model has a page with how it connects, its setup steps, its guide and the controls its panel gets.',++ },{path: '/docs', title: 'Build for OpenVibe.Bot',note: 'The build index: drivers and robot profiles.',@@@ -103,8 +114,9 @@ const LLMS_PAGES = [// bundle: the page imports the pinned ESM build OpenVibe.Live uses from esm.sh, and its viewer// signaling endpoint is OpenRestream's wss host (OpenRestream README "playback descriptor"). Exactly these two// sources, on the panel and embed pages only — the site-wide CSP (server/app.js) stays as it is.--const PANEL_SCRIPT_SRC = 'https://esm.sh';--const PANEL_CONNECT_SRC = 'wss://ingest.openre.stream';++// Plus Cloudflare's Web Analytics beacon, which the edge adds to every HTML page.++const PANEL_SCRIPT_SRC = 'https://esm.sh https://static.cloudflareinsights.com';++const PANEL_CONNECT_SRC = 'wss://ingest.openre.stream https://cloudflareinsights.com';const PANEL_CSP = ["default-src 'self'", `script-src 'self' ${PANEL_SCRIPT_SRC}`, `connect-src 'self' ${PANEL_CONNECT_SRC}`,"frame-ancestors 'self'", "object-src 'none'", "base-uri 'self'"].join('; ');/** The embed's own CSP: the panel's sources plus the frame-ancestors allow-list. */@@@ -115,12 +127,21 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limiconst r = express.Router();// 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')));++ 'Disallow: /robots/new', '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));// The sitemap lists every public GET page (openvibe-shared/seo); robots.txt already names it.-- r.get('/sitemap.xml', (req, res) => {-- res.type('application/xml').send(seo.sitemapXml(PUBLIC_PAGES.map((p) => ({ loc: `${config.baseUrl}${p}`, lastmod: releaseLastmod }))));++ r.get('/sitemap.xml', async (req, res, next) => {++ try {++ const pages = PUBLIC_PAGES.map((p) => ({ loc: `${config.baseUrl}${p}`, lastmod: releaseLastmod }));++ if (domain) {++ for (const row of await listProfiles(domain.db)) pages.push({ loc: `${config.baseUrl}/models/${row.profile.id}`, lastmod: releaseLastmod });++ for (const robot of await domain.robots.listListed({ limit: 200 })) {++ pages.push({ loc: `${config.baseUrl}/r/${robot.id}`, lastmod: new Date(robot.updated_at).toISOString() });++ }++ }++ res.type('application/xml').send(seo.sitemapXml(pages));++ } catch (e) { next(e); }});// /llms.txt and /llms-full.txt (llmstxt.org, openvibe-shared/seo): the public pages as a plain-text map for// language-model crawlers. Only the pages above; the signed-in pages, the API and /internal are not listed.@@@ -150,6 +171,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limir.get('/panel/sim-world.js', asset('sim-world.js', 'application/javascript'));r.get('/panel/sim-view.js', asset('sim-view.js', 'application/javascript'));r.get('/panel/try-device.js', asset('try-device.js', 'application/javascript'));++ r.get('/directory.css', asset('directory.css', 'text/css'));// /try: the real panel driving a rover simulated in the visitor's own browser (public/try-device.js), so anyone// can try it with no account and no hardware (plan T15 "a simulated robot in the browser"). Nothing reaches the@@@ -228,16 +250,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi});// The embed flag is web-only: domain.present.robot (and so /api/v1) does not carry it.-- const presentForPage = (row) => ({ ...domain.present.robot(row), embed_public: !!row.embed_public });---- async function robotsPage(req, res, me, extra = {}) {-- const robots = (await domain.robots.list(me.subject)).map(presentForPage);-- const profiles = (await listProfiles(domain.db)).map((p) => p.profile);-- // A kit's "Add this robot" arrives as ?profile=<id>: the add form opens with that model chosen.-- const asked = typeof req.query.profile === 'string' && profiles.some((p) => p.id === req.query.profile) ? req.query.profile : null;-- const values = extra.values || (asked ? { profile_id: asked } : {});-- res.type('html').send(renderRobotsPage({ robots, profiles, kits: kits.list(), ...extra, values }));-- }++ const presentForPage = (row) => ({ ...domain.present.robot(row), embed_public: !!row.embed_public, listed: !!row.listed });// 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.@@@ -248,7 +261,61 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limires.type('html').send(renderHome({ config, signedIn: !!req.viewer }));});-- r.get('/robots', page(async (req, res) => robotsPage(req, res, requireUser(req))));++ // ── The directory (plan T15 "a robot directory (/robots)"): public pages inside the OpenVibe Frame. ──++ const catalogue = async () => (await listProfiles(domain.db)).map((row) => modelOf(row.profile));++ const namesOf = async (subjects) => {++ if (!identity || !subjects.length) return new Map();++ try { return await identity.names([...new Set(subjects)]); } catch (e) { log.warn(`[Bot] directory names: ${e.message}`); return new Map(); }++ };++ const onlineOf = async (id) => { try { return !!(hub && (await hub.robotState(id)).online); } catch { return false; } };++++ r.get('/robots', frameCsp, page(async (req, res) => {++ res.setHeader('Cache-Control', 'private, no-cache');++ res.setHeader('Vary', 'Cookie');++ const models = await catalogue();++ const byId = new Map(models.map((m) => [m.id, m]));++ const modelName = (row) => (byId.get(row.profile_id) || { name: row.profile_id.startsWith('local.') ? 'Its own profile' : row.profile_id }).name;++ const rows = await domain.robots.listListed({ limit: 60 });++ const names = await namesOf(rows.map((r) => r.owner_subject));++ const listed = await Promise.all(rows.map(async (row) => ({++ id: row.id, name: row.name, access_policy: row.access_policy, model_name: modelName(row),++ owner: names.get(row.owner_subject) || null, online: await onlineOf(row.id),++ })));++ const mine = req.viewer ? (await domain.robots.list(req.viewer.subject)).map((row) => ({ id: row.id, name: row.name, listed: !!row.listed, model_name: modelName(row) })) : null;++ res.type('html').send(renderDirectory({ config, listed, mine, models, signedIn: !!req.viewer }));++ }));++ r.get('/models', frameCsp, page(async (req, res) => {++ res.setHeader('Cache-Control', 'public, max-age=300');++ res.type('html').send(renderModels({ config, models: await catalogue() }));++ }));++ r.get('/models/:id', frameCsp, page(async (req, res) => {++ const model = (await catalogue()).find((m) => m.id === req.params.id);++ if (!model) throw new BotError(404, 'bot.profile_not_found', 'no such model');++ res.setHeader('Cache-Control', 'public, max-age=300');++ res.type('html').send(renderModel({ config, model, kit: kits.list().find((k) => k.profile_id === model.id) || null }));++ }));++ // A listed robot's public page; its owner and members see their own robot's page whether it is listed or not.++ r.get('/r/:id', frameCsp, page(async (req, res) => {++ res.setHeader('Cache-Control', 'private, no-cache');++ res.setHeader('Vary', 'Cookie');++ const robot = await domain.robots.get(req.params.id);++ const role = robot && req.viewer ? await memberRole(robot, req.viewer) : null;++ if (!robot || (!robot.listed && !role)) throw new BotError(404, 'bot.robot_not_found', 'no such robot');++ const row = await getProfile(domain.db, robot.profile_id, robot.profile_version);++ const model = row ? modelOf(row.profile) : { id: robot.profile_id, name: robot.profile_id };++ const owner = (await namesOf([robot.owner_subject])).get(robot.owner_subject) || null;++ const state = { online: await onlineOf(robot.id) };++ res.type('html').send(renderRobotPage({ config, robot: { id: robot.id, name: robot.name, access_policy: robot.access_policy }, model, owner, state, role: role === 'queue' ? null : role }));++ }));++ // Adding a robot (the five-minute path's first step): name it, choose what it is, who may drive.++ async function addPage(req, res, extra = {}) {++ res.setHeader('Content-Security-Policy', HOME_CSP);++ const models = (await catalogue()).filter((m) => !m.id.startsWith('local.'));++ const asked = typeof req.query.profile === 'string' && models.some((m) => m.id === req.query.profile) ? req.query.profile : null;++ const values = extra.values || (asked ? { profile_id: asked } : {});++ res.type('html').send(renderAddRobot({ config, models, values, error: extra.error || null }));++ }++ r.get('/robots/new', page(async (req, res) => { requireUser(req); return addPage(req, res); }));r.post('/robots', ...manage, express.urlencoded({ extended: false, limit: '8kb' }), page(async (req, res) => {const me = requireUser(req);@@@ -259,7 +326,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limitry {({ robot, pairing } = await domain.robots.create({ owner: me.subject, name: values.name, profile_id: values.profile_id, access_policy: values.access_policy }));} catch (e) {-- if (e.status === 422) { res.status(422); return robotsPage(req, res, me, { error: e.detail || e.code, values }); }++ if (e.status === 422) { res.status(422); return addPage(req, res, { error: e.detail || e.code, values }); }throw e;}// A simulated robot needs no machine: straight to its panel. Otherwise the code create minted is shown here.@@@ -284,6 +351,19 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limires.type('html').send(renderPairingPage({ robot: domain.present.robot(robot), pairing, profile: await profileOf(robot) }));}));++ r.post('/robots/:id/listing', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {++ const me = requireUser(req);++ if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');++ const robot = await domain.robots.get(req.params.id);++ if (!robot) throw new BotError(404, 'bot.robot_not_found', 'no such robot');++ if (robot.owner_subject !== me.subject) throw new BotError(403, 'bot.forbidden', 'only the owner may list a robot');++ const sent = req.body && req.body.listed;++ const value = Array.isArray(sent) ? sent[sent.length - 1] : sent;++ if (value !== 'on' && value !== 'off') throw new BotError(422, 'bot.invalid_input', 'listed must be on or off');++ await domain.robots.setListed(robot.id, value === 'on');++ res.redirect(303, `/panel/${robot.id}`);++ }));++r.post('/robots/:id/embed', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {const me = requireUser(req);if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');nnew file mode 100644@@@ -0,0 +1,116 @@++'use strict';++// The robot directory (plan T15 "a robot directory (/robots)"): /robots is public (listed robots, the models, and a++// signed-in person's own robots), an owner lists a robot from its panel, a listed robot has a public page (/r/:id)++// that watches it through the embeddable panel, every model has a page, and adding a robot starts at /robots/new.++const assert = require('assert');++const { boot, check, done } = require('./helpers/app');++const { loadProfiles } = require('../server/profiles');++++(async () => {++ const t = await boot();++ const alex = t.network.newUser('alex');++ const bob = t.network.newUser('bob');++ const cookie = (user) => ({ Cookie: `ov_token=${t.network.signUser(user)}` });++ const get = (p, user) => fetch(t.base + p, { redirect: 'manual', headers: user ? cookie(user) : {} });++ const post = (p, user, body, headers = {}) => fetch(t.base + p, {++ method: 'POST', redirect: 'manual', body,++ headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...(user ? cookie(user) : {}), ...headers },++ });++ const profiles = [...loadProfiles().values()];++ const { robot } = await t.robot(alex, { name: 'Garage rover', profile_id: 'adeept.adr036', access_policy: 'queue' });++++ await check('GET /robots signed out: the models and the way in, no one\'s robots, inside the Frame', async () => {++ const r = await get('/robots');++ assert.strictEqual(r.status, 200);++ assert.match(r.headers.get('cache-control'), /private/);++ const html = await r.text();++ assert.ok(html.includes('<h1>Robots</h1>') && html.includes('navbar-mount') && html.includes('/directory.css'));++ for (const p of profiles) assert.ok(html.includes(`href="/models/${p.id}"`), p.id);++ assert.ok(html.includes('href="/try"') && html.includes('Sign in and add a robot'));++ assert.ok(!html.includes('Your robots') && !html.includes('Garage rover'), 'an unlisted robot is nobody else\'s business');++ assert.ok(html.includes('No robot is listed yet.'));++ });++++ await check('signed in: your robots, each marked listed or not, and Add a robot', async () => {++ const html = await (await get('/robots', alex)).text();++ assert.ok(html.includes('Your robots') && html.includes(`href="/panel/${robot.id}"`) && html.includes('Not listed'));++ assert.ok(html.includes('href="/robots/new"'));++ assert.ok(!(await (await get('/robots', bob)).text()).includes('Garage rover'));++ });++++ await check('the owner lists it: it shows in the directory and on its own page, watchable by anyone', async () => {++ const listing = `/robots/${robot.id}/listing`;++ assert.strictEqual((await post(listing, bob, 'listed=off&listed=on')).status, 403, 'only the owner');++ assert.strictEqual((await post(listing, alex, 'listed=off&listed=on', { Origin: 'https://evil.test' })).status, 403, 'no cross-site post');++ assert.strictEqual((await post(listing, alex, 'listed=maybe')).status, 422);++ const on = await post(listing, alex, 'listed=off&listed=on');++ assert.strictEqual(on.status, 303);++ assert.strictEqual(on.headers.get('location'), `/panel/${robot.id}`);++ const row = await t.domain.robots.get(robot.id);++ assert.ok(row.listed && row.listed_at && row.embed_public, 'listing makes it watchable');++ const dir = await (await get('/robots')).text();++ assert.ok(dir.includes(`href="/r/${robot.id}"`) && dir.includes('Garage rover') && dir.includes('Adeept ADR036 4WD Smart Car'));++ assert.ok(dir.includes('Anyone signed in can take a timed turn.'));++ const page = await get(`/r/${robot.id}`);++ assert.strictEqual(page.status, 200);++ const html = await page.text();++ assert.ok(html.includes(`<iframe src="/panel/${robot.id}/embed"`), 'it watches through the embeddable panel');++ assert.ok(html.includes(`href="/panel/${robot.id}"`) && html.includes('Take a turn'), 'a queue robot offers a turn');++ assert.match(page.headers.get('content-security-policy'), /frame-src 'self'/);++ assert.strictEqual((await get(`/panel/${robot.id}/embed`)).status, 200, 'and the frame answers a stranger');++ const panel = await (await get(`/panel/${robot.id}`, alex)).text();++ assert.ok(panel.includes(`action="/robots/${robot.id}/listing"`) && /name="listed" value="on" checked/.test(panel) && panel.includes(`href="/r/${robot.id}"`));++ assert.ok((await (await get('/sitemap.xml')).text()).includes(`/r/${robot.id}</loc>`));++ });++++ await check('unlisting takes it out; turning off watching unlists it; its page is then the owner\'s only', async () => {++ await post(`/robots/${robot.id}/listing`, alex, 'listed=off');++ let row = await t.domain.robots.get(robot.id);++ assert.ok(!row.listed && row.embed_public, 'unlisting leaves embedding as it was');++ assert.ok(!(await (await get('/robots')).text()).includes(`/r/${robot.id}`));++ assert.strictEqual((await get(`/r/${robot.id}`)).status, 404);++ assert.strictEqual((await get(`/r/${robot.id}`, alex)).status, 200, 'its owner still sees its page');++ await post(`/robots/${robot.id}/listing`, alex, 'listed=on');++ await post(`/robots/${robot.id}/embed`, alex, 'embed_public=off');++ row = await t.domain.robots.get(robot.id);++ assert.ok(!row.listed && !row.embed_public, 'a robot nobody may watch is not listed');++ assert.strictEqual((await get('/r/rob_nothing')).status, 404);++ });++++ await check('/models and /models/:id: every model by how it connects, its setup and its panel', async () => {++ const r = await get('/models');++ assert.strictEqual(r.status, 200);++ assert.match(r.headers.get('cache-control'), /^public/);++ const html = await r.text();++ for (const label of ['Simulated', 'On-board', 'Bridge', 'Server-side']) assert.ok(html.includes(`>${label}</h2>`), label);++ const cozmo = await (await get('/models/cozmo')).text();++ assert.ok(cozmo.includes('<h1>Cozmo') && cozmo.includes('How it connects') && cozmo.includes('hardware-cozmo.md') && cozmo.includes('href="/robots/new?profile=cozmo"'));++ const sim = await (await get('/models/sim.rover')).text();++ assert.ok(sim.includes('href="/try"'), 'the simulated rover points at /try');++ assert.strictEqual((await get('/models/nope')).status, 404);++ const sitemap = await (await get('/sitemap.xml')).text();++ for (const p of profiles) assert.ok(sitemap.includes(`/models/${p.id}</loc>`), p.id);++ });++++ await check('/robots/new: signed in only; one choice per model, grouped by how it connects, the asked one chosen', async () => {++ const out = await get('/robots/new');++ assert.strictEqual(out.status, 302);++ assert.strictEqual(out.headers.get('location'), `/auth/login?next=${encodeURIComponent('/robots/new')}`);++ const html = await (await get('/robots/new', alex)).text();++ for (const p of profiles) assert.ok(html.includes(`name="profile_id" value="${p.id}"`), p.id);++ assert.match(html, /value="sim\.rover" checked/, 'the simulated rover by default');++ assert.match(await (await get('/robots/new?profile=looi', alex)).text(), /value="looi" checked/);++ const made = await post('/robots', alex, 'name=Desk+friend&profile_id=sim.rover&access_policy=private');++ assert.strictEqual(made.status, 303, 'a simulated rover goes straight to its panel');++ });++++ await check('robots.txt: the directory, models and robot pages are crawlable; adding and panels are not', async () => {++ const txt = await (await get('/robots.txt')).text();++ assert.ok(txt.includes('Disallow: /robots/new\n') && txt.includes('Disallow: /panel/\n'));++ assert.ok(!/Disallow: \/robots\n|Disallow: \/r\/|Disallow: \/models/.test(txt));++ });++++ t.app.locals.sim.stopAll();++ await t.close();++ done();++})();@@@ -60,12 +60,10 @@ const { loadConfig, frameAncestors } = require('../server/config');assert.strictEqual(on.status, 303);assert.strictEqual(on.headers.get('location'), `/panel/${robot.id}`);assert.strictEqual(await flag(robot.id), true);-- for (const p of [`/panel/${robot.id}`, '/robots']) {-- const html = await (await get(p, alex)).text();-- assert.match(html, new RegExp(`action="/robots/${robot.id}/embed"`), p);-- assert.match(html, /name="embed_public" value="on" checked/, p);-- assert.match(html, /never the controls/, p);-- }++ const html = await (await get(`/panel/${robot.id}`, alex)).text();++ assert.match(html, new RegExp(`action="/robots/${robot.id}/embed"`));++ assert.match(html, /name="embed_public" value="on" checked/);++ assert.match(html, /never the controls/);const r = await t.call('GET', `/api/v1/robots/${robot.id}`, { user: alex });assert.ok(!r.text.includes('embed_public'));const off = await post(url, alex, { embed_public: 'off' });@@@ -55,7 +55,7 @@ const { renderPanel } = require('../server/web/render');let ipN = 0;// Each watcher from its own address unless one is named (the test server trusts one proxy hop, as production).const watch = (ip = `10.0.0.${++ipN}`) => t.ws('/watch', { headers: { 'X-Forwarded-For': ip } });-- const EMBED_CSP = "default-src 'self'; script-src 'self' https://esm.sh; connect-src 'self' wss://ingest.openre.stream; frame-ancestors 'self' https://openvibe.live https://www.openvibe.live; object-src 'none'; base-uri 'self'";++ const EMBED_CSP = "default-src 'self'; script-src 'self' https://esm.sh https://static.cloudflareinsights.com; connect-src 'self' wss://ingest.openre.stream https://cloudflareinsights.com; frame-ancestors 'self' https://openvibe.live https://www.openvibe.live; object-src 'none'; base-uri 'self'";const auditCount = async (id) => (await t.domain.audit.list(id, { limit: 100 })).length;const { robot: open } = await t.robot(alex, { name: 'Public rover' });@@@ -74,7 +74,7 @@ const { renderPanel } = require('../server/web/render');assert.ok(t.hub.isOnline(`dev_sim_${open.id}`), 'the embed starts the simulator as the panel does');// The rest of the site still refuses every frame.assert.match((await get(`/panel/${open.id}`, alex)).headers.get('content-security-policy'), /frame-ancestors 'self';/);-- assert.match((await get('/robots', alex)).headers.get('content-security-policy'), /frame-ancestors 'self';/);++ assert.match((await get('/robots', alex)).headers.get('content-security-policy'), /frame-ancestors 'none';/, 'the directory (a Frame page) refuses every frame');});await check('a robot that is not public: 403 with a link out and no robot data; unknown: 404; members keep their role', async () => {@@@ -47,8 +47,8 @@ const { boot, check, done } = require('./helpers/app');});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'");++ const r = await get('/pair/rob_nothing', alex);++ assert.strictEqual(r.headers.get('content-security-policy'), "default-src 'self'; script-src 'self' https://static.cloudflareinsights.com; connect-src 'self' https://cloudflareinsights.com; frame-ancestors 'self'; object-src 'none'; base-uri 'self'");});await check('the vhost proxies / and /shared/ to the app; the legal pages are the app\'s, not the Sites files', async () => {@@@ -100,18 +100,17 @@ const MINIMAL = {assert.strictEqual(missing.json.code, 'bot.kit_not_found');});-- await check('the robots page offers "Get a robot": the kit, its parts, its guide and a start with its profile chosen', async () => {++ await check('a kit\'s model page offers it: the parts, the kit\'s build guide, and a start with its profile chosen', async () => {const alex = t.network.newUser('alex');-- const page = (q = '') => fetch(`${t.base}/robots${q}`, { redirect: 'manual', headers: { Cookie: `ov_token=${t.network.signUser(alex)}` } }).then((r) => r.text());-- const html = await page();-- assert.match(html, /<h2 id="kits-h">Get a robot<\/h2>/);-- assert.match(html, /Adeept ADR036 4WD Smart Car Kit for Raspberry Pi/);-- assert.match(html, /What you need/);-- assert.match(html, /href="https:\/\/www\.adeept\.com\/learn\/detail-97\.html" target="_blank" rel="noopener">Build guide/);-- assert.match(html, /href="\/robots\?profile=adeept\.adr036#add-robot">Add this robot/);++ const page = (p) => fetch(`${t.base}${p}`, { redirect: 'manual', headers: { Cookie: `ov_token=${t.network.signUser(alex)}` } }).then((r) => r.text());++ const html = await page('/models/adeept.adr036');++ assert.match(html, /<h2>What you need<\/h2>/);++ assert.match(html, /Adeept Robot HAT/);++ assert.match(html, /href="https:\/\/www\.adeept\.com\/learn\/detail-97\.html" rel="noopener">The kit's build guide/);++ assert.match(html, /href="\/robots\/new\?profile=adeept\.adr036">Add this robot/);assert.doesNotMatch(html, /\(O30\)/, 'no internal plan codes in public copy');-- assert.match(await page('?profile=adeept.adr036'), /<option value="adeept\.adr036" selected>/, 'the kit start chooses its profile');-- assert.doesNotMatch(await page('?profile=nope'), /<option value="nope"/);++ assert.match(await page('/robots/new?profile=adeept.adr036'), /value="adeept\.adr036" checked/, 'the kit start chooses its profile');++ assert.doesNotMatch(await page('/robots/new?profile=nope'), /value="nope"/);});await t.close();@@@ -148,10 +148,12 @@ const allowedFor = (profile, role) => [...(DEFAULT_ALLOW[role] || []).filter((k)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);++ for (const p of ['/auth/', '/api/', '/robots/new', '/pair/', '/panel/', '/install']) assert.ok(robots.includes(`Disallow: ${p}\n`), p);++ assert.ok(!robots.includes('Disallow: /robots\n'), 'the directory is public');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']) {++ assert.strictEqual((await get('/robots')).status, 200, 'the directory is public');++ for (const p of ['/robots/new', '/panel/rob_x', '/pair/rob_x']) {const r = await get(p);assert.strictEqual(r.status, 302, p);assert.strictEqual(r.headers.get('location'), `/auth/login?next=${encodeURIComponent(p)}`);@@@ -185,7 +187,7 @@ const allowedFor = (profile, role) => [...(DEFAULT_ALLOW[role] || []).filter((k)await check('the panel\'s CSP opens exactly esm.sh and OpenRestream\'s signaling; the rest of the site keeps its own', async () => {const csp = (await get(`/panel/${simId}`, alex)).headers.get('content-security-policy');assert.strictEqual(csp,-- "default-src 'self'; script-src 'self' https://esm.sh; connect-src 'self' wss://ingest.openre.stream; frame-ancestors 'self'; object-src 'none'; base-uri 'self'");++ "default-src 'self'; script-src 'self' https://esm.sh https://static.cloudflareinsights.com; connect-src 'self' wss://ingest.openre.stream https://cloudflareinsights.com; frame-ancestors 'self'; object-src 'none'; base-uri 'self'");for (const p of ['/robots', `/pair/${simId}`]) {const other = (await get(p, alex)).headers.get('content-security-policy') || '';assert.ok(!other.includes('esm.sh') && !other.includes('ingest.openre.stream'), `${p} was widened`);@@@ -328,7 +330,7 @@ const allowedFor = (profile, role) => [...(DEFAULT_ALLOW[role] || []).filter((k)await check('a bad form answers 422 with the form; a cross-site post is refused', async () => {const bad = await post('/robots', alex, { name: '', profile_id: 'sim.rover' });assert.strictEqual(bad.status, 422);-- assert.match(await bad.text(), /class="error"/);++ assert.match(await bad.text(), /class="dir-error" role="alert"/);const unknown = await post('/robots', alex, { name: 'X', profile_id: 'nope' });assert.strictEqual(unknown.status, 422);const cross = await post('/robots', alex, { name: 'X', profile_id: 'sim.rover' }, { Origin: 'https://evil.test' });@@@ -53,7 +53,7 @@ const { loadProfiles } = require('../server/profiles');assert.ok(json, 'the profile rides along as data');assert.deepStrictEqual(JSON.parse(json[1]), loadProfiles().get('sim.rover'));assert.ok(html.includes('<link rel="canonical" href="https://openvibe.bot/try">') && html.includes('<meta name="description"'));-- assert.ok(html.includes('href="/robots#add-robot"'), 'the way on to a real robot');++ assert.ok(html.includes('href="/robots/new"'), 'the way on to a real robot');for (const s of scripts) {const js = await get(s);assert.strictEqual(js.status, 200, s);
@@ -15,6 +15,7 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato
@@ -15,6 +15,7 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato
- The `/device` and `/control` WebSockets, the command gate and the latched e-stop.
- The `/device` and `/control` WebSockets, the command gate and the latched e-stop.
- The 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 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 rover lives in a small arena (`public/sim-world.js`, shared by the server and the browser): walls and crates stop it, its telemetry carries a range finder (`distance_cm`), three line sensors over a strip of tape and its pose, and its panel draws the camera from that pose (`public/sim-view.js`) with a map of the arena in the corner.
- 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 rover lives in a small arena (`public/sim-world.js`, shared by the server and the browser): walls and crates stop it, its telemetry carries a range finder (`distance_cm`), three line sensors over a strip of tape and its pose, and its panel draws the camera from that pose (`public/sim-view.js`) with a map of the arena in the corner.
+- The robot directory (`server/web/directory.js`, inside the OpenVibe Frame): `/robots` is public and lists the robots their owners list (online state, model, owner, who may drive), every model Bot drives and, signed in, your own robots; `/models` and `/models/:id` say how each model connects, its setup steps, its guide and the controls its panel gets; `/r/:id` is a listed robot's public page, watching it live through the embeddable panel; `/robots/new` adds one (name, what it is, who may drive). The owner lists a robot from its panel (`POST /robots/:id/listing`, migration 0009): listing turns on watching (`embed_public`), and turning watching off unlists it.
- `/try`, public and without an account: the same panel on a rover that runs in the visitor's own browser (`public/try-device.js` answers the panel's `/control` frames with the gate's shaping, deadman and latched e-stop), so anyone can drive before owning hardware. Nothing after the page reaches the server. 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; a tile plays the robot's OpenRestream WebRTC session when one is live (the mediasoup-client viewer flow OpenVibe.Live runs, against the session's playback descriptor), and stays the placeholder when there is none.
- `/try`, public and without an account: the same panel on a rover that runs in the visitor's own browser (`public/try-device.js` answers the panel's `/control` frames with the gate's shaping, deadman and latched e-stop), so anyone can drive before owning hardware. Nothing after the page reaches the server. 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; a tile plays the robot's OpenRestream WebRTC session when one is live (the mediasoup-client viewer flow OpenVibe.Live runs, against the session's playback descriptor), and stays the placeholder when there is none.
- The legal pages (`/terms`, `/privacy`, `/dmca`, `openvibe-shared/legal`), `/sitemap.xml`, `/manifest.webmanifest`, `/robots.txt` and the browser-facing 404 (`server/app.js`, `server/web/routes.js`): Bot serves them all itself, and the nginx vhost has no static root.
- The legal pages (`/terms`, `/privacy`, `/dmca`, `openvibe-shared/legal`), `/sitemap.xml`, `/manifest.webmanifest`, `/robots.txt` and the browser-facing 404 (`server/app.js`, `server/web/routes.js`): Bot serves them all itself, and the nginx vhost has no static root.
- The public build pages (`server/web/docs.js`): `/docs` (an index), `/docs/drivers` (write a driver for the OpenVibe Node — the plugin protocol, every message, the safety rules and how to test), and `/docs/profiles` (the `bot.robot-profile@1` fields, a full example and a validator). The validator answers `POST /docs/profiles/validate` (a form) and `POST /api/v1/profiles/validate` (JSON in, `{ valid, problems[] }` out) by running the same checks a shipped profile gets (`server/profiles` `checkProfile`); the pages carry no script and run under the site-wide CSP.
- The public build pages (`server/web/docs.js`): `/docs` (an index), `/docs/drivers` (write a driver for the OpenVibe Node — the plugin protocol, every message, the safety rules and how to test), and `/docs/profiles` (the `bot.robot-profile@1` fields, a full example and a validator). The validator answers `POST /docs/profiles/validate` (a form) and `POST /api/v1/profiles/validate` (JSON in, `{ valid, problems[] }` out) by running the same checks a shipped profile gets (`server/profiles` `checkProfile`); the pages carry no script and run under the site-wide CSP.
@@ -41,8 +41,9 @@
@@ -41,8 +41,9 @@
"command audit kept 30 days, and the bot.robot.online/offline, bot.estop.set/cleared and bot.command.refused outbox events",
"command audit kept 30 days, and the bot.robot.online/offline, bot.estop.set/cleared and bot.command.refused outbox events",
"public robot-profile catalogue (GET /profiles, /profiles/:id); robots are private to their owner, with public control only as a timed turn queue",
"public robot-profile catalogue (GET /profiles, /profiles/:id); robots are private to their owner, with public control only as a timed turn queue",
"per-actor limits on capability-boundary writes (openvibe-sdk/limits; shared through Valkey when VALKEY_URL is set)",
"per-actor limits on capability-boundary writes (openvibe-sdk/limits; shared through Valkey when VALKEY_URL is set)",
- "signed-in pages: /robots (list and add), /pair/:id (the code and the one-paste installer command) and /panel/:id, rendered from the robot's profile and driven over /control; a sim.rover robot is driven by an in-process simulated device in a small arena shared with the browser (public/sim-world.js: walls and crates stop it; telemetry carries distance_cm, line sensors and its pose, from which the panel draws its camera and a map)",
+ "signed-in pages: /robots/new (add), /pair/:id (the code and the one-paste installer command) and /panel/:id, rendered from the robot's profile and driven over /control; a sim.rover robot is driven by an in-process simulated device in a small arena shared with the browser (public/sim-world.js: walls and crates stop it; telemetry carries distance_cm, line sensors and its pose, from which the panel draws its camera and a map)",
"/try (public, no account): the real panel on a sim.rover that runs in the visitor's browser (public/try-device.js answers /control locally with the gate's value shaping, the deadman and the latched e-stop); listed in the sitemap and llms.txt",
"/try (public, no account): the real panel on a sim.rover that runs in the visitor's browser (public/try-device.js answers /control locally with the gate's value shaping, the deadman and the latched e-stop); listed in the sitemap and llms.txt",
+ "the robot directory (Frame pages, server/web/directory.js): public /robots (listed robots with online state, model and owner; every model; a signed-in person's own robots), /models and /models/:id (how it connects, setup steps, guide, panel controls), /r/:id (a listed robot's public page with its live embeddable panel), /robots/new (name, model choice grouped by connection kind, who may drive); owners list from the panel (POST /robots/:id/listing, robots.listed, migration 0009), listing turns on embed_public and unwatching unlists; listed robots and every model are in the sitemap",
"the panel's People card: the owner adds an operator or viewer by @username (OpenVibe.Network resolves the name to a subject) and removes them again, shown with Network's names; a signed-in visitor on an open-queue robot sees their turn or their place in line and can leave the queue, promoting the next person (POST /robots/:id/operators and …/operators/:subject/remove, POST /robots/:id/queue/leave)",
"the panel's People card: the owner adds an operator or viewer by @username (OpenVibe.Network resolves the name to a subject) and removes them again, shown with Network's names; a signed-in visitor on an open-queue robot sees their turn or their place in line and can leave the queue, promoting the next person (POST /robots/:id/operators and …/operators/:subject/remove, POST /robots/:id/queue/leave)",
"panel input: a touch joystick, the keyboard and a gamepad (Gamepad API), each held control re-sent while held and zeroed at once on release; a latency meter (round-trip median and telemetry age); one camera tile per camera the profile lists, playing the robot's live OpenRestream WebRTC session (the Adeept and Cozmo profiles carry a camera widget since 2026-10-13); a readout shows the sensor keys the Node's drivers report (distance_cm, line, cliff and picked_up); a live online indicator on /pair/:id read from /control",
"panel input: a touch joystick, the keyboard and a gamepad (Gamepad API), each held control re-sent while held and zeroed at once on release; a latency meter (round-trip median and telemetry age); one camera tile per camera the profile lists, playing the robot's live OpenRestream WebRTC session (the Adeept and Cozmo profiles carry a camera widget since 2026-10-13); a readout shows the sensor keys the Node's drivers report (distance_cm, line, cliff and picked_up); a live online indicator on /pair/:id read from /control",
"platform.job@1 jobs dispatched to a paired Node over /device (platform.job-frame@1: resent unacked on reconnect, only to an advertised runtime class) and metered per second into run_usage_outbox for billing.usage.record",
"platform.job@1 jobs dispatched to a paired Node over /device (platform.job-frame@1: resent unacked on reconnect, only to an advertised runtime class) and metered per second into run_usage_outbox for billing.usage.record",
@@ -51,5 +52,5 @@
@@ -51,5 +52,5 @@
"streaming and recording toggles per robot, off by default and owner-only: media records live sessions to OpenVibe.Media (the OpenRestream 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 OpenRestream 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-13"
+ "updated": "2026-10-14"
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
+-- phase: expand
+--
+-- The robot directory (plan T15, /robots): an owner may list a robot publicly. A listed robot has a public page
+-- (/r/:id) and appears in the directory with its model and online state; anyone may watch it there (read-only, the
+-- /watch view), so listing also turns on embed_public. Additive only: off for every existing robot.
+
+ALTER TABLE robots ADD COLUMN listed boolean NOT NULL DEFAULT false;
+ALTER TABLE robots ADD COLUMN listed_at timestamptz;
+CREATE INDEX IF NOT EXISTS robots_listed_idx ON robots (listed_at DESC) WHERE listed;
new file mode 100644
new file mode 100644
@@ -0,0 +1,81 @@
@@ -0,0 +1,81 @@
+/* OpenVibe.Bot's directory pages (/robots, /models, /models/:id, /r/:id, /robots/new) inside the OpenVibe Frame.
+ Colours come from the Frame's theme variables, so the pages follow the person's theme (light or dark). */
+.dir { font-family: var(--ov-font, 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif); max-width: 1120px; margin: 0 auto; padding: 28px 16px 64px; color: var(--text-primary, #e6edf7); font-size: 15.5px; line-height: 1.6; }
+.dir a { color: var(--accent-light, #60a5fa); text-underline-offset: 2px; }
+.dir .muted { color: var(--text-secondary, #96a7c2); }
+.dir code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: .9em; background: var(--bg-secondary, #101828); border: 1px solid var(--border, #1f2d47); border-radius: 5px; padding: 1px 5px; }
+.dir-crumb { margin: 0 0 10px; font-size: 14px; color: var(--text-secondary, #96a7c2); }
+.dir-hero { margin: 0 0 26px; max-width: 760px; }
+.dir-hero h1 { font-size: clamp(28px, 4.2vw, 40px); line-height: 1.12; letter-spacing: -.02em; margin: 0 0 10px; }
+.dir-hero p { margin: 0 0 10px; }
+.dir-hero > p.muted { font-size: 17px; }
+
+.dir-sec { margin: 0 0 30px; }
+.dir-sec h2 { font-size: 19px; letter-spacing: -.01em; margin: 0 0 12px; }
+.dir-sec-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-bottom: 12px; }
+.dir-sec-head h2 { margin: 0; }
+.dir .card { background: var(--bg-secondary, #101828); border: 1px solid var(--border, #1f2d47); border-radius: 14px; padding: 18px 20px; }
+.dir .card h2 { font-size: 13px; font-weight: 750; letter-spacing: .07em; text-transform: uppercase; color: var(--text-secondary, #96a7c2); }
+.dir .card ul, .dir .card ol { margin: 0 0 10px; padding-left: 20px; }
+.dir .card li { margin: 4px 0; }
+.dir-cols { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; margin-bottom: 30px; }
+.dir-cols .dir-sec { margin: 0; }
+
+.dir-actions { display: flex; flex-wrap: wrap; gap: 10px; margin: 14px 0 0; }
+.dir-btn { display: inline-flex; align-items: center; min-height: 42px; padding: 0 16px; border-radius: 10px; font-weight: 650; font-size: 14.5px; text-decoration: none; border: 1px solid var(--border, #1f2d47); background: var(--bg-secondary, #101828); color: var(--text-primary, #e6edf7) !important; cursor: pointer; font-family: inherit; }
+.dir-btn:hover { border-color: var(--accent, #3b82f6); }
+.dir-btn.dir-primary { background: var(--accent-strong, var(--accent, #3472d8)); border-color: transparent; color: var(--on-accent-strong, #fff) !important; }
+.dir-btn.dir-primary:hover { filter: brightness(1.08); }
+
+.dir-grid { list-style: none; margin: 0; padding: 0; display: grid; gap: 14px; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); }
+.dir-card { background: var(--bg-secondary, #101828); border: 1px solid var(--border, #1f2d47); border-radius: 14px; display: flex; flex-direction: column; transition: border-color .15s; }
+.dir-card:hover { border-color: var(--accent, #3b82f6); }
+.dir-card-link { display: grid; gap: 6px; padding: 16px 18px 10px; color: inherit !important; text-decoration: none; }
+.dir-card-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
+.dir-card-head b { font-size: 16px; }
+.dir-card-text { color: var(--text-secondary, #96a7c2); font-size: 14px; }
+.dir-card-foot { margin: auto 0 0; padding: 0 18px 14px; font-size: 13px; color: var(--text-secondary, #96a7c2); }
+.dir-chips { list-style: none; display: flex; flex-wrap: wrap; gap: 6px; margin: 0; padding: 0 18px 16px; }
+.dir .card .dir-chips { padding: 0; }
+.dir-chips li { font-size: 12.5px; padding: 2px 9px; border-radius: 999px; border: 1px solid var(--border, #1f2d47); color: var(--text-secondary, #96a7c2); margin: 0; }
+
+.dir-badge { flex: none; font-size: 11.5px; font-weight: 700; letter-spacing: .03em; padding: 2px 8px; border-radius: 999px; border: 1px solid currentColor; }
+.dir-browser { color: #a78bfa; } .dir-onboard { color: #22c55e; } .dir-bridge { color: #f59e0b; } .dir-server { color: #38bdf8; }
+.dir-pill { display: inline-block; font-size: 12.5px; padding: 2px 10px; border-radius: 999px; border: 1px solid var(--border, #1f2d47); text-decoration: none; color: var(--text-primary, #e6edf7) !important; }
+.dir-pill.dir-quiet { color: var(--text-secondary, #96a7c2) !important; }
+.dir-state { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; font-weight: 650; color: var(--text-secondary, #96a7c2); }
+.dir-dot { width: 8px; height: 8px; border-radius: 50%; background: #64748b; }
+.dir-state[data-state="online"] { color: #22c55e; }
+.dir-state[data-state="online"] .dir-dot { background: #22c55e; box-shadow: 0 0 0 3px rgba(34, 197, 94, .2); }
+
+.dir-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
+.dir-row { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 14px; padding: 12px 16px; border: 1px solid var(--border, #1f2d47); border-radius: 12px; background: var(--bg-secondary, #101828); }
+.dir-row > a b { font-size: 15.5px; }
+.dir-row-actions { margin-left: auto; font-size: 14px; }
+.dir-cta { background: var(--bg-secondary, #101828); border: 1px solid var(--border, #1f2d47); border-radius: 16px; padding: 20px 22px; }
+
+.dir-live { border: 1px solid var(--border, #1f2d47); border-radius: 16px; overflow: hidden; background: #0b0f15; }
+.dir-live iframe { display: block; width: 100%; height: min(78vh, 760px); border: 0; }
+.dir-note { font-size: 13.5px; margin-top: 10px; }
+
+.dir-form { display: grid; gap: 18px; max-width: 860px; }
+.dir-field { display: grid; gap: 6px; max-width: 420px; }
+.dir-field-label { font-size: 13px; font-weight: 750; letter-spacing: .05em; text-transform: uppercase; color: var(--text-secondary, #96a7c2); }
+.dir-form input[type="text"], .dir-form select { font: inherit; min-height: 44px; padding: 0 12px; border-radius: 10px; border: 1px solid var(--border, #1f2d47); background: var(--bg-primary, #0a0f1c); color: var(--text-primary, #e6edf7); }
+.dir-form input[type="text"]:focus, .dir-form select:focus { outline: 2px solid var(--accent, #3b82f6); outline-offset: 1px; }
+.dir-fieldset { border: 0; margin: 0; padding: 0; display: grid; gap: 8px; }
+.dir-group { margin: 10px 0 2px; font-size: 14px; font-weight: 650; }
+.dir-choices { display: grid; gap: 10px; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); }
+.dir-choice { position: relative; display: flex; gap: 10px; align-items: flex-start; padding: 14px 16px; border: 1px solid var(--border, #1f2d47); border-radius: 12px; background: var(--bg-secondary, #101828); cursor: pointer; }
+.dir-choice:hover { border-color: var(--accent, #3b82f6); }
+.dir-choice:has(input:checked) { border-color: var(--accent, #3b82f6); box-shadow: 0 0 0 1px var(--accent, #3b82f6) inset; }
+.dir-choice input { margin-top: 4px; accent-color: var(--accent, #3b82f6); }
+.dir-choice-body { display: grid; gap: 4px; }
+.dir-error { border: 1px solid rgba(239, 68, 68, .45); background: rgba(239, 68, 68, .08); border-radius: 10px; padding: 12px 14px; margin: 0; }
+
+@media (max-width: 760px) {
+ .dir { padding-top: 18px; }
+ .dir-cols { grid-template-columns: 1fr; }
+ .dir-row-actions { margin-left: 0; width: 100%; }
+ .dir-live iframe { height: 86vh; }
+}
@@ -99,7 +99,9 @@ function createApp(opts = {}) {
@@ -99,7 +99,9 @@ function createApp(opts = {}) {
app.use((req, res, next) => {
app.use((req, res, next) => {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
- res.setHeader('Content-Security-Policy', ["default-src 'self'", "frame-ancestors 'self'", "object-src 'none'", "base-uri 'self'"].join('; '));
+ // Cloudflare's edge adds its Web Analytics beacon to HTML pages: its script and its report are the only outside sources.
+ res.setHeader('Content-Security-Policy', ["default-src 'self'", "script-src 'self' https://static.cloudflareinsights.com", "connect-src 'self' https://cloudflareinsights.com",
+ "frame-ancestors 'self'", "object-src 'none'", "base-uri 'self'"].join('; '));
next();
next();
});
});
app.use(cookieParser());
app.use(cookieParser());
@@ -240,9 +240,24 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -240,9 +240,24 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
}
}
/** Opt a robot in or out of anonymous read-only embedding (the owner check is the caller's; no event). */
/** Opt a robot in or out of anonymous read-only embedding (the owner check is the caller's; no event). */
async function setEmbedPublic(id, value) {
async function setEmbedPublic(id, value) {
- await db.query('UPDATE robots SET embed_public = $2, updated_at = $3 WHERE id = $1', [id, !!value, iso(now())]);
+ // Unlisting is part of stopping public watching: a listed robot is always watchable.
+ await db.query(`UPDATE robots SET embed_public = $2, updated_at = $3${value ? '' : ', listed = false, listed_at = NULL'} WHERE id = $1`, [id, !!value, iso(now())]);
return getRobot(id);
return getRobot(id);
}
}
+ /**
+ * List a robot in the public directory (/robots, its page /r/:id) or take it out. Listing makes it watchable by
+ * anyone (embed_public), since a directory entry nobody can watch is no entry; unlisting leaves embedding as it was.
+ * The owner check is the caller's.
+ */
+ async function setListed(id, value) {
+ if (value) await db.query('UPDATE robots SET listed = true, listed_at = COALESCE(listed_at, $2), embed_public = true, updated_at = $2 WHERE id = $1', [id, iso(now())]);
+ else await db.query('UPDATE robots SET listed = false, listed_at = NULL, updated_at = $2 WHERE id = $1', [id, iso(now())]);
+ return getRobot(id);
+ }
+ /** The listed robots, newest listing first (the directory). */
+ async function listListed({ limit = 60 } = {}) {
+ return db.many('SELECT * FROM robots WHERE listed ORDER BY listed_at DESC, id LIMIT $1', [Math.max(1, Math.min(200, limit))]);
+ }
/**
/**
* Remove a robot. Its OpenRestream stream's key is revoked first (rotated with no grace, its sessions ended), so
* Remove a robot. Its OpenRestream stream's key is revoked first (rotated with no grace, its sessions ended), so
* nothing publishes as the robot afterwards; an OpenRestream that refuses or does not answer leaves the robot in
* nothing publishes as the robot afterwards; an OpenRestream that refuses or does not answer leaves the robot in
@@ -998,7 +1013,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -998,7 +1013,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
return {
return {
db, now, config, log, outbox, link,
db, now, config, log, outbox, link,
present: { robot: presentRobot, device: presentDevice, video: presentVideo },
present: { robot: presentRobot, device: presentDevice, video: presentVideo },
- robots: { create: createRobot, list: listRobots, get: getRobot, update: updateRobot, setEmbedPublic, remove: removeRobot,
+ robots: { create: createRobot, list: listRobots, get: getRobot, update: updateRobot, setEmbedPublic, setListed, listListed, remove: removeRobot,
localProfile, saveLocalProfile, useCatalogueProfile },
localProfile, saveLocalProfile, useCatalogueProfile },
members: { roleOf, add: addOperator, remove: removeOperator, list: listOperators },
members: { roleOf, add: addOperator, remove: removeOperator, list: listOperators },
pairing: { create: createPairingCode, redeem, prune: prunePairingCodes, installerCommand, driverForProfile, whipUrl },
pairing: { create: createPairingCode, redeem, prune: prunePairingCodes, installerCommand, driverForProfile, whipUrl },
new file mode 100644
new file mode 100644
@@ -0,0 +1,210 @@
@@ -0,0 +1,210 @@
+'use strict';
+
+/**
+ * The robot directory (plan T15 "a robot directory (/robots)"), inside the OpenVibe Frame like the front page:
+ *
+ * renderDirectory({ config, listed, mine, models, signedIn }) GET /robots: robots people list, the models Bot
+ * drives and, signed in, your own robots
+ * renderModels({ config, models }) GET /models
+ * renderModel({ config, model, kit }) GET /models/:id: what it is, how it connects, the
+ * panel it gets, its setup steps and guide
+ * renderRobotPage({ config, robot, model, owner, state, role }) GET /r/:id: a listed robot's public page (its live
+ * panel, watch-only, in a frame of this site)
+ * renderAddRobot({ config, models, values, error }) GET /robots/new: name it, choose what it is, who drives
+ *
+ * Plain markup, no script (the Frame's own aside), every value escaped. A model is a catalogue profile; MODELS adds
+ * what a profile does not say (how it connects in words, the setup steps, the guide). An unknown profile still gets
+ * a page from its own fields.
+ */
+const { esc } = require('./render');
+const { framePage } = require('./home');
+
+const NODE_BLOB = 'https://github.com/OpenVibers/OpenVibe.Node/blob/main';
+const CONNECT = {
+ browser: { label: 'Simulated', text: 'Runs inside Bot and in your browser: nothing to buy or install.' },
+ onboard: { label: 'On-board', text: 'The OpenVibe Node runs on the robot\'s own computer and drives its hardware.' },
+ bridge: { label: 'Bridge', text: 'The OpenVibe Node runs on a computer or phone next to the robot and joins the robot\'s own link. Nothing on the robot changes.' },
+ server: { label: 'Server-side', text: 'Nothing to install: Bot connects to the device itself.' },
+};
+const MODELS = {
+ 'sim.rover': {
+ connect: 'browser', order: 0,
+ setup: ['Add it and its panel drives straight away; the simulator runs inside Bot.', 'Or drive one at /try without an account.'],
+ guide: { href: '/try', label: 'Try it now, no account' },
+ },
+ 'adeept.adr036': {
+ connect: 'onboard', order: 1,
+ setup: ['Build the kit and flash Raspberry Pi OS (64-bit, Lite is enough) on its card.', 'Add the robot here and paste the one pairing command on the Pi.',
+ 'The OpenVibe Node drives the Robot HAT itself, publishes the Pi camera, and stops the motors the moment commands stop. The kit\'s stock control server is never run.'],
+ guide: { href: `${NODE_BLOB}/docs/hardware-adeept.md`, label: 'Adeept setup guide' },
+ },
+ 'adeept.adr036.mecanum': {
+ connect: 'onboard', order: 2,
+ setup: ['The same kit built with mecanum wheels, so it also moves sideways.', 'Pair it with the same one command; the installer picks the mecanum driver.'],
+ guide: { href: `${NODE_BLOB}/docs/hardware-adeept.md`, label: 'Adeept setup guide' },
+ },
+ cozmo: {
+ connect: 'bridge', order: 3,
+ setup: ['Cozmo keeps its own firmware and app; nothing on it changes.', 'On a computer beside it (one network adapter on Cozmo\'s Wi-Fi, one on the internet) paste the pairing command.',
+ 'The bridge drives it through PyCozmo and streams its camera; cliff and pick-up stop it on the robot.'],
+ guide: { href: `${NODE_BLOB}/docs/hardware-cozmo.md`, label: 'Cozmo setup guide' },
+ },
+ looi: {
+ connect: 'bridge', order: 4,
+ setup: ['The community\'s Generic LOOI driver bridges to the desk robot over Bluetooth from a Windows PC.', 'Install the Node with --driver looi; the phone in LOOI keeps doing what it does.'],
+ guide: { href: `${NODE_BLOB}/docs/hardware-looi.md`, label: 'LOOI driver contract' },
+ },
+ 'camera.onvif': {
+ connect: 'server', order: 5,
+ setup: ['Any ONVIF pan-tilt-zoom camera: Bot moves it, with the same gate and limits as a robot.', 'Today an operator names the camera in Bot\'s configuration (its address and the names of its secrets).'],
+ guide: { href: 'https://github.com/OpenVibers/OpenVibe.Bot#readme', label: 'ONVIF notes' },
+ },
+ 'relay.generic': {
+ connect: 'onboard', order: 6,
+ setup: ['Any Raspberry Pi or Linux machine running the OpenVibe Node.', 'Each button on the panel is passed to a script of yours by the relay driver: lights, a door, a motor board.'],
+ guide: { href: '/docs/drivers', label: 'Write a driver' },
+ },
+};
+const CAPABILITY_WORDS = {
+ 'drive.differential': 'Drives', 'drive.mecanum': 'Drives any direction', 'servo.pan_tilt': 'Pan / tilt camera', head: 'Head', lift: 'Lift',
+ 'lights.rgb': 'RGB lights', 'lights.backpack': 'Backpack lights', 'lights.cube': 'Cube lights', 'speaker.horn': 'Horn', 'speaker.say': 'Speaks',
+ 'display.text': 'Face and text', 'sensor.ultrasonic': 'Range finder', 'sensor.line': 'Line tracking', 'sensor.cliff': 'Cliff sensors',
+ 'sensor.pickup': 'Pick-up sensor', battery: 'Battery', ptz: 'Pan, tilt, zoom', camera: 'Live camera', relay: 'Your own buttons',
+};
+const POLICY_WORDS = { private: 'Only its owner and the people they add drive it.', invite: 'People the owner invites drive it.', queue: 'Anyone signed in can take a timed turn.' };
+
+/** A catalogue profile as a model: its own fields plus MODELS. */
+function modelOf(profile) {
+ const extra = MODELS[profile.id] || {};
+ const connect = extra.connect || (profile.kind === 'bridge' ? 'bridge' : profile.kind === 'server' ? 'server' : 'onboard');
+ return {
+ id: profile.id, name: profile.name, vendor: profile.vendor || '', description: profile.description || '',
+ connect, order: extra.order != null ? extra.order : 100, setup: extra.setup || [], guide: extra.guide || null,
+ capabilities: (profile.capabilities || []).filter((c) => CAPABILITY_WORDS[c]), widgets: profile.widgets || [], profile,
+ };
+}
+const sortModels = (models) => models.slice().sort((a, b) => a.order - b.order || a.name.localeCompare(b.name));
+
+const chips = (caps) => `<ul class="dir-chips">${caps.map((c) => `<li>${esc(CAPABILITY_WORDS[c])}</li>`).join('')}</ul>`;
+const badge = (connect) => `<span class="dir-badge dir-${esc(connect)}">${esc(CONNECT[connect].label)}</span>`;
+const link = (href, text, cls = '') => `<a${cls ? ` class="${cls}"` : ''} href="${esc(href)}"${/^https?:/.test(href) ? ' rel="noopener"' : ''}>${esc(text)}</a>`;
+/** A card's line: the description's first clause, cut at a word near `max` characters. */
+function short(text, max = 120) {
+ const first = String(text).split(/(?<=[.:;])\s/)[0].replace(/[:;]$/, '.');
+ if (first.length <= max) return /[.!?]$/.test(first) ? first : `${first}.`;
+ return `${first.slice(0, first.lastIndexOf(' ', max)).replace(/[,(]$/, '')}…`;
+}
+const ownerName = (o) => (o && o.username ? `@${o.username}` : 'an OpenVibe member');
+
+function shell({ config, path, title, description, body }) {
+ return framePage({ config, title: `${title} · OpenVibe.Bot`, description, canonicalPath: path, head: '<link rel="stylesheet" href="/directory.css">', body: `<main id="main" class="dir">${body}</main>` });
+}
+
+function modelCard(m) {
+ return `<li class="dir-card"><a class="dir-card-link" href="/models/${esc(m.id)}"><span class="dir-card-head"><b>${esc(m.name)}</b>${badge(m.connect)}</span>`
+ + `<span class="dir-card-text">${esc(short(m.description))}</span></a>${chips(m.capabilities.slice(0, 6))}</li>`;
+}
+
+function robotCard(r) {
+ return `<li class="dir-card"><a class="dir-card-link" href="/r/${esc(r.id)}"><span class="dir-card-head"><b>${esc(r.name)}</b>`
+ + `<span class="dir-state" data-state="${r.online ? 'online' : 'offline'}"><span class="dir-dot"></span>${r.online ? 'Online' : 'Offline'}</span></span>`
+ + `<span class="dir-card-text">${esc(r.model_name)} · ${esc(ownerName(r.owner))}</span></a>`
+ + `<p class="dir-card-foot">${esc(POLICY_WORDS[r.access_policy] || '')}</p></li>`;
+}
+
+// ── GET /robots ─────────────────────────────────────────────────────────────────────────────────────
+function renderDirectory({ config, listed = [], mine = null, models = [], signedIn = false }) {
+ const yours = !signedIn ? `<section class="dir-sec dir-cta"><h2>Have a robot?</h2>
+<p class="muted">Sign in to add it and pair it with one pasted command, or drive a simulated rover first, no account needed.</p>
+<p class="dir-actions">${link('/try', 'Try the panel', 'dir-btn dir-primary')} ${link('/auth/login?next=%2Frobots%2Fnew', 'Sign in and add a robot', 'dir-btn')}</p></section>`
+ : `<section class="dir-sec" aria-labelledby="yours-h"><div class="dir-sec-head"><h2 id="yours-h">Your robots</h2>${link('/robots/new', 'Add a robot', 'dir-btn dir-primary')}</div>
+${mine && mine.length ? `<ul class="dir-list">${mine.map((r) => `<li class="dir-row"><a href="/panel/${esc(r.id)}"><b>${esc(r.name)}</b></a><span class="muted">${esc(r.model_name)}</span>`
+ + `${r.listed ? `<a class="dir-pill" href="/r/${esc(r.id)}">Listed</a>` : '<span class="dir-pill dir-quiet">Not listed</span>'}`
+ + `<span class="dir-row-actions">${link(`/panel/${r.id}`, 'Panel')} · ${link(`/pair/${r.id}`, 'Pair a device')}</span></li>`).join('')}</ul>`
+ : '<p class="muted">No robots yet. Add one: a simulated rover drives straight away, before any hardware exists.</p>'}</section>`;
+ const body = `<header class="dir-hero"><h1>Robots</h1>
+<p class="muted">Robots people share on OpenVibe.Bot, and every model it drives. Watch any listed robot live; some take visitors at the controls in turns.</p></header>
+${yours}
+<section class="dir-sec" aria-labelledby="listed-h"><h2 id="listed-h">In the directory</h2>
+${listed.length ? `<ul class="dir-grid">${listed.map(robotCard).join('')}</ul>`
+ : `<p class="muted">No robot is listed yet. An owner lists theirs from its panel (Directory), and it shows here with its live camera.</p>`}</section>
+<section class="dir-sec" aria-labelledby="models-h"><div class="dir-sec-head"><h2 id="models-h">Models it drives</h2>${link('/models', 'All models')}</div>
+<ul class="dir-grid">${sortModels(models).map(modelCard).join('')}</ul></section>
+<section class="dir-sec" aria-labelledby="byo-h"><h2 id="byo-h">Something else?</h2>
+<p class="muted">Any robot with a computer on it or next to it can join: a Raspberry Pi or other Linux board, an ESP32, a ROS 2 robot, an ONVIF camera. Describe it with a profile and the panel draws itself; write a driver in any language for the hardware.</p>
+<p class="dir-actions">${link('/docs/profiles', 'Robot profiles', 'dir-btn')} ${link('/docs/drivers', 'Write a driver', 'dir-btn')}</p></section>`;
+ return shell({ config, path: '/robots', title: 'Robots', description: 'Robots on OpenVibe.Bot: watch the ones people list live, see every model it drives, and add your own.', body });
+}
+
+// ── GET /models ─────────────────────────────────────────────────────────────────────────────────────
+function renderModels({ config, models = [] }) {
+ const groups = ['browser', 'onboard', 'bridge', 'server'].map((k) => [k, sortModels(models).filter((m) => m.connect === k)]).filter(([, ms]) => ms.length);
+ const body = `<header class="dir-hero"><h1>Models</h1>
+<p class="muted">Every robot OpenVibe.Bot has a profile for. A profile lays out the panel, so each of these drives with the controls it really has.</p></header>
+${groups.map(([k, ms]) => `<section class="dir-sec" aria-labelledby="g-${esc(k)}"><h2 id="g-${esc(k)}">${esc(CONNECT[k].label)}</h2><p class="muted">${esc(CONNECT[k].text)}</p>
+<ul class="dir-grid">${ms.map(modelCard).join('')}</ul></section>`).join('\n')}
+<section class="dir-sec"><h2>Not here?</h2><p class="muted">Write a profile for it and check it with the validator; a driver for its hardware runs under the OpenVibe Node.</p>
+<p class="dir-actions">${link('/docs/profiles', 'Robot profiles', 'dir-btn')} ${link('/docs/drivers', 'Write a driver', 'dir-btn')}</p></section>`;
+ return shell({ config, path: '/models', title: 'Models', description: 'The robots OpenVibe.Bot drives: kits, bridged robots like Cozmo and LOOI, ONVIF cameras and a simulated rover.', body });
+}
+
+// ── GET /models/:id ─────────────────────────────────────────────────────────────────────────────────
+function renderModel({ config, model, kit = null }) {
+ const controls = model.widgets.filter((w) => w.type !== 'latency').map((w) => `<li>${esc(w.label || w.type)}</li>`).join('');
+ const parts = kit ? `<section class="dir-sec card"><h2>What you need</h2><ul>${kit.parts.map((p) => `<li>${esc(p.qty)}× ${esc(p.name)}${p.note ? ` <span class="muted">(${esc(p.note)})</span>` : ''}</li>`).join('')}</ul>
+${kit.build_guide_url ? `<p>${link(kit.build_guide_url, 'The kit\'s build guide')}</p>` : ''}</section>` : '';
+ const add = model.connect === 'browser'
+ ? `${link('/try', 'Try it now, no account', 'dir-btn dir-primary')} ${link(`/robots/new?profile=${encodeURIComponent(model.id)}`, 'Add one to your robots', 'dir-btn')}`
+ : `${link(`/robots/new?profile=${encodeURIComponent(model.id)}`, 'Add this robot', 'dir-btn dir-primary')}${model.guide ? ` ${link(model.guide.href, model.guide.label, 'dir-btn')}` : ''}`;
+ const body = `<p class="dir-crumb">${link('/robots', 'Robots')} / ${link('/models', 'Models')}</p>
+<header class="dir-hero"><h1>${esc(model.name)}</h1><p>${badge(model.connect)}${model.vendor ? ` <span class="muted">by ${esc(model.vendor)}</span>` : ''}</p>
+<p class="muted">${esc(model.description)}</p><p class="dir-actions">${add}</p></header>
+<div class="dir-cols">
+<section class="dir-sec card"><h2>How it connects</h2><p><b>${esc(CONNECT[model.connect].label)}.</b> ${esc(CONNECT[model.connect].text)}</p>
+${model.setup.length ? `<ol>${model.setup.map((s) => `<li>${esc(s)}</li>`).join('')}</ol>` : ''}</section>
+<section class="dir-sec card"><h2>Its panel</h2><p class="muted">Laid out from its profile (<code>${esc(model.id)}</code>):</p><ul>${controls}</ul>${chips(model.capabilities)}</section>
+</div>
+${parts}
+<section class="dir-sec card"><h2>Safety</h2><p class="muted">Every command passes Bot's gate: roles, the owner's limits and cooldowns, and a latched e-stop only the owner clears. The device stops the motors by itself when commands stop arriving, without asking the network.</p></section>`;
+ return shell({ config, path: `/models/${model.id}`, title: model.name, description: `${model.name} on OpenVibe.Bot: how it connects, the panel it gets and how to set it up.`, body });
+}
+
+// ── GET /r/:id ──────────────────────────────────────────────────────────────────────────────────────
+function renderRobotPage({ config, robot, model, owner = null, state = null, role = null }) {
+ const online = !!(state && state.online);
+ const turn = robot.access_policy === 'queue' ? link(`/panel/${robot.id}`, 'Take a turn at the controls', 'dir-btn dir-primary') : '';
+ const member = role ? link(`/panel/${robot.id}`, role === 'owner' ? 'Open your panel' : 'Open the panel', 'dir-btn dir-primary') : '';
+ const body = `<p class="dir-crumb">${link('/robots', 'Robots')}</p>
+<header class="dir-hero"><h1>${esc(robot.name)}</h1>
+<p><span class="dir-state" data-state="${online ? 'online' : 'offline'}"><span class="dir-dot"></span>${online ? 'Online' : 'Offline'}</span> ${link(`/models/${model.id}`, model.name, 'dir-pill')} <span class="muted">by ${esc(ownerName(owner))}</span></p>
+<p class="muted">${esc(POLICY_WORDS[robot.access_policy] || '')}</p>
+${member || turn ? `<p class="dir-actions">${member || turn}</p>` : ''}</header>
+<div class="dir-live"><iframe src="/panel/${esc(robot.id)}/embed" title="${esc(robot.name)}: live panel" loading="lazy" allow="autoplay; gamepad"></iframe></div>
+<p class="muted dir-note">Watching is open to everyone; driving follows the owner's rules and passes the same gate and e-stop as on its own panel.</p>`;
+ return shell({ config, path: `/r/${robot.id}`, title: robot.name, description: `${robot.name}, a ${model.name} on OpenVibe.Bot. ${POLICY_WORDS[robot.access_policy] || ''}`.trim(), body });
+}
+
+// ── GET /robots/new ─────────────────────────────────────────────────────────────────────────────────
+const POLICIES = [['private', 'Only me and people I add'], ['invite', 'People I invite'], ['queue', 'Anyone signed in, taking turns']];
+function renderAddRobot({ config, models = [], values = {}, error = null }) {
+ const chosen = values.profile_id || 'sim.rover';
+ const option = (m) => `<label class="dir-choice"><input type="radio" name="profile_id" value="${esc(m.id)}"${m.id === chosen ? ' checked' : ''} required>`
+ + `<span class="dir-choice-body"><span class="dir-card-head"><b>${esc(m.name)}</b>${badge(m.connect)}</span><span class="dir-card-text">${esc(CONNECT[m.connect].text)}</span></span></label>`;
+ const groups = [['browser', 'Try it with no hardware'], ['onboard', 'A robot with its own computer'], ['bridge', 'A robot you connect through a computer next to it'], ['server', 'A camera Bot connects to']]
+ .map(([k, title]) => [title, sortModels(models).filter((m) => m.connect === k)]).filter(([, ms]) => ms.length);
+ const body = `<p class="dir-crumb">${link('/robots', 'Robots')}</p>
+<header class="dir-hero"><h1>Add a robot</h1><p class="muted">Name it, say what it is, and choose who may drive. A simulated rover drives the moment it is added; anything else gets a one-time pairing code and one command to paste on its computer.</p></header>
+<form class="dir-form" method="post" action="/robots">
+${error ? `<p class="dir-error" role="alert">${esc(error)}</p>` : ''}
+<label class="dir-field"><span class="dir-field-label">Name</span><input type="text" name="name" maxlength="80" required autocomplete="off" placeholder="e.g. Kitchen rover" value="${esc(values.name || '')}"></label>
+<fieldset class="dir-fieldset"><legend class="dir-field-label">What are you connecting?</legend>
+${groups.map(([title, ms]) => `<p class="dir-group">${esc(title)}</p><div class="dir-choices">${ms.map(option).join('')}</div>`).join('\n')}
+<p class="muted dir-note">Something else (an ESP32, a ROS 2 robot, your own board)? Choose the relay robot and drive it with your own script, or ${link('/docs/profiles', 'write a profile')} for it.</p>
+</fieldset>
+<label class="dir-field"><span class="dir-field-label">Who may drive</span><select name="access_policy">${POLICIES.map(([v, l]) => `<option value="${esc(v)}"${v === (values.access_policy || 'private') ? ' selected' : ''}>${esc(l)}</option>`).join('')}</select></label>
+<p><button type="submit" class="dir-btn dir-primary">Add robot</button></p>
+</form>`;
+ return shell({ config, path: '/robots/new', title: 'Add a robot', description: 'Add a robot to OpenVibe.Bot.', body });
+}
+
+module.exports = { renderDirectory, renderModels, renderModel, renderRobotPage, renderAddRobot, modelOf, sortModels, MODELS, CONNECT, CAPABILITY_WORDS };
@@ -26,12 +26,12 @@ const DESCRIPTION = 'An open control panel for robots: pair a robot with one pas
@@ -26,12 +26,12 @@ const DESCRIPTION = 'An open control panel for robots: pair a robot with one pas
const HOME_CSP = [
const HOME_CSP = [
"default-src 'self'",
"default-src 'self'",
// The Frame's theme-loader, navbar and footer are served from /shared; the inline boot is the shell's.
// 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",
+ "script-src 'self' 'unsafe-inline' https://openvibe.network https://static.cloudflareinsights.com",
"style-src 'self' 'unsafe-inline' https://openvibe.network https://fonts.googleapis.com https://cdnjs.cloudflare.com",
"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",
"font-src 'self' data: https://fonts.gstatic.com https://cdnjs.cloudflare.com",
"img-src 'self' data: https:",
"img-src 'self' data: https:",
// The navbar asks the Network for the session, menus and notifications (events: realtime and release notices).
// The navbar asks the Network for the session, menus and notifications (events: realtime and release notices).
- "connect-src 'self' https://openvibe.network https://openvibe.events",
+ "connect-src 'self' https://openvibe.network https://openvibe.events https://cloudflareinsights.com",
"frame-src 'self' https://openvibe.network",
"frame-src 'self' https://openvibe.network",
"frame-ancestors 'none'",
"frame-ancestors 'none'",
"object-src 'none'",
"object-src 'none'",
@@ -252,6 +252,15 @@ function renderWidget(w, { profile = {}, allowed_commands = [], signaling_url =
@@ -252,6 +252,15 @@ function renderWidget(w, { profile = {}, allowed_commands = [], signaling_url =
const toggle = (name, on, title, note, disabled = false, was = false) => `${was ? `<input type="hidden" name="${esc(name)}_was" value="${on ? 'on' : 'off'}">` : ''}<input type="hidden" name="${esc(name)}" value="off">
const toggle = (name, on, title, note, disabled = false, was = false) => `${was ? `<input type="hidden" name="${esc(name)}_was" value="${on ? 'on' : 'off'}">` : ''}<input type="hidden" name="${esc(name)}" value="off">
<label class="toggle"><input type="checkbox" name="${esc(name)}" value="on"${on ? ' checked' : ''}${disabled ? ' disabled' : ''}><span><b>${esc(title)}</b><small>${esc(note)}</small></span></label>`;
<label class="toggle"><input type="checkbox" name="${esc(name)}" value="on"${on ? ' checked' : ''}${disabled ? ' disabled' : ''}><span><b>${esc(title)}</b><small>${esc(note)}</small></span></label>`;
+/** The owner's directory switch: list the robot on /robots with a public page (/r/:id), watchable by anyone. */
+function listingForm(robot) {
+ return `<section class="setting-card" aria-labelledby="listing-h"><h2 id="listing-h">Directory</h2>
+<form class="embed-form" method="post" action="/robots/${esc(robot.id)}/listing">
+${toggle('listed', robot.listed, 'List it in the robot directory', 'It gets a public page where anyone can watch it live (never drive it, unless your queue lets them take a turn). Listing also turns on watching where it is embedded.')}
+<button type="submit">Save</button>
+</form>${robot.listed ? `<p class="setting-note"><a href="/r/${esc(robot.id)}">Its public page</a></p>` : ''}</section>`;
+}
+
/** The owner's embed switch: plain markup, works without JavaScript. On the panel it is a card; in the robots list, a compact row. */
/** The owner's embed switch: plain markup, works without JavaScript. On the panel it is a card; in the robots list, a compact row. */
function embedForm(robot, { card = true } = {}) {
function embedForm(robot, { card = true } = {}) {
const form = `<form class="embed-form${card ? '' : ' embed-inline'}" method="post" action="/robots/${esc(robot.id)}/embed" data-embed-form>
const form = `<form class="embed-form${card ? '' : ' embed-inline'}" method="post" action="/robots/${esc(robot.id)}/embed" data-embed-form>
@@ -390,7 +399,7 @@ ${embed && role === 'watcher' && !signedIn ? `<p class="embed-sign-in" data-driv
@@ -390,7 +399,7 @@ ${embed && role === 'watcher' && !signedIn ? `<p class="embed-sign-in" data-driv
<div class="widgets">
<div class="widgets">
${widgets.join('\n')}
${widgets.join('\n')}
</div>
</div>
-${tryIt ? tryOutro(profile) : ''}${role === 'owner' && !embed && !tryIt ? `<div class="owner-settings">${localProfileForm(robot, profile, profileForm)}${embedForm(robot)}${streamingForm(robot, streaming)}${people ? peopleCard(robot, people) : ''}</div>` : ''}
+${tryIt ? tryOutro(profile) : ''}${role === 'owner' && !embed && !tryIt ? `<div class="owner-settings">${localProfileForm(robot, profile, profileForm)}${listingForm(robot)}${embedForm(robot)}${streamingForm(robot, streaming)}${people ? peopleCard(robot, people) : ''}</div>` : ''}
</main>`;
</main>`;
return page(tryIt ? 'Try the panel with a simulated rover' : robot.name, body, {
return page(tryIt ? 'Try the panel with a simulated rover' : robot.name, body, {
scripts: panelScripts(profile, tryIt), bodyClass: embed ? 'embed-page' : 'panel-page',
scripts: panelScripts(profile, tryIt), bodyClass: embed ? 'embed-page' : 'panel-page',
@@ -416,7 +425,7 @@ function tryOutro(profile) {
@@ -416,7 +425,7 @@ function tryOutro(profile) {
return `<section class="try-next card" aria-labelledby="try-next-h">
return `<section class="try-next card" aria-labelledby="try-next-h">
<h2 id="try-next-h">Ready for a real one?</h2>
<h2 id="try-next-h">Ready for a real one?</h2>
<p>Add a robot, pair its computer with one pasted command, and this panel drives it. A Raspberry Pi rover, a Cozmo through a nearby computer, an ONVIF camera or a phone are all a few minutes away.</p>
<p>Add a robot, pair its computer with one pasted command, and this panel drives it. A Raspberry Pi rover, a Cozmo through a nearby computer, an ONVIF camera or a phone are all a few minutes away.</p>
-<p class="try-actions"><a class="button primary" href="/robots#add-robot">Add a robot</a> <a class="button quiet" href="/docs/profiles">How a profile draws this panel</a> <a class="button quiet" href="/docs/drivers">Write a driver</a></p>
+<p class="try-actions"><a class="button primary" href="/robots/new">Add a robot</a> <a class="button quiet" href="/models">See the models</a> <a class="button quiet" href="/docs/profiles">How a profile draws this panel</a> <a class="button quiet" href="/docs/drivers">Write a driver</a></p>
</section>
</section>
<script type="application/json" id="try-profile">${JSON.stringify(profile).replace(/</g, '\\u003c')}</script>`;
<script type="application/json" id="try-profile">${JSON.stringify(profile).replace(/</g, '\\u003c')}</script>`;
}
}
@@ -458,7 +467,7 @@ function kitsSection(kits = [], profiles = []) {
@@ -458,7 +467,7 @@ function kitsSection(kits = [], profiles = []) {
return `<li class="kit card"><div class="kit-head">${icon('robot', 'icon kit-icon')}<div><h3>${esc(k.name)}</h3>${k.vendor ? `<span class="pill">${esc(k.vendor)}</span>` : ''}</div></div>
return `<li class="kit card"><div class="kit-head">${icon('robot', 'icon kit-icon')}<div><h3>${esc(k.name)}</h3>${k.vendor ? `<span class="pill">${esc(k.vendor)}</span>` : ''}</div></div>
<p class="kit-desc">${esc(k.description || '')}</p>
<p class="kit-desc">${esc(k.description || '')}</p>
<details class="kit-parts"><summary>What you need <span class="muted">(${esc(need)} ${need === 1 ? 'part' : 'parts'})</span></summary><ul>${k.parts.map(part).join('')}</ul></details>
<details class="kit-parts"><summary>What you need <span class="muted">(${esc(need)} ${need === 1 ? 'part' : 'parts'})</span></summary><ul>${k.parts.map(part).join('')}</ul></details>
-<p class="kit-actions"><a class="button primary" href="/robots?profile=${encodeURIComponent(k.profile_id)}#add-robot">Add this robot</a> ${outLink(k.build_guide_url, 'Build guide', 'button quiet')}</p></li>`;
+<p class="kit-actions"><a class="button primary" href="/robots/new?profile=${encodeURIComponent(k.profile_id)}">Add this robot</a> ${outLink(k.build_guide_url, 'Build guide', 'button quiet')}</p></li>`;
};
};
return `<section class="kits" aria-labelledby="kits-h"><h2 id="kits-h">Get a robot</h2>
return `<section class="kits" aria-labelledby="kits-h"><h2 id="kits-h">Get a robot</h2>
<p class="muted">Curated kits with a ready-made profile: build one, pair it, and its panel already knows every motor and sensor.</p>
<p class="muted">Curated kits with a ready-made profile: build one, pair it, and its panel already knows every motor and sensor.</p>
@@ -36,10 +36,11 @@ const { getProfile, listProfiles, loadProfiles } = require('../profiles');
@@ -36,10 +36,11 @@ const { getProfile, listProfiles, loadProfiles } = require('../profiles');
const { frameAncestors } = require('../config');
const { frameAncestors } = require('../config');
const { BotError } = require('../util');
const { BotError } = require('../util');
const kits = require('../kits');
const kits = require('../kits');
-const { renderPanel, renderEmbedRefused, renderDrivePage, renderRobotsPage, renderPairingPage } = require('./render');
+const { renderPanel, renderEmbedRefused, renderDrivePage, renderPairingPage } = require('./render');
const { renderDocsIndex, renderDriversPage, renderProfilesPage } = require('./docs');
const { renderDocsIndex, renderDriversPage, renderProfilesPage } = require('./docs');
const { checkProfile } = require('../profiles');
const { checkProfile } = require('../profiles');
const { renderHome, HOME_CSP } = require('./home');
const { renderHome, HOME_CSP } = require('./home');
+const { renderDirectory, renderModels, renderModel, renderRobotPage, renderAddRobot, modelOf } = require('./directory');
const PUBLIC = path.join(__dirname, '..', '..', 'public');
const PUBLIC = path.join(__dirname, '..', '..', 'public');
const HOLD_RESEND_MS = 150;
const HOLD_RESEND_MS = 150;
@@ -49,7 +50,7 @@ const TRY_PROFILE = loadProfiles().get('sim.rover');
@@ -49,7 +50,7 @@ const TRY_PROFILE = loadProfiles().get('sim.rover');
const SITE_TAGLINE = 'An open control panel for robots';
const SITE_TAGLINE = 'An open control panel for robots';
// Every public GET page, for the sitemap. The interactive pages (/robots, /pair/, /panel/) need a session and
// Every public GET page, for the sitemap. The interactive pages (/robots, /pair/, /panel/) need a session and
// are Disallowed in robots.txt; the API and sign-in are not pages.
// are Disallowed in robots.txt; the API and sign-in are not pages.
-const PUBLIC_PAGES = ['/', '/try', '/install', '/docs', '/docs/drivers', '/docs/profiles', '/terms', '/privacy', '/dmca'];
+const PUBLIC_PAGES = ['/', '/try', '/robots', '/models', '/install', '/docs', '/docs/drivers', '/docs/profiles', '/terms', '/privacy', '/dmca'];
// The public pages an AI crawler can read, for /llms.txt and /llms-full.txt (llmstxt.org). Same pages as the
// The public pages an AI crawler can read, for /llms.txt and /llms-full.txt (llmstxt.org). Same pages as the
// sitemap minus /install (a 302 to the installer script, not a page); no signed-in or per-robot page, nothing
// sitemap minus /install (a 302 to the installer script, not a page); no signed-in or per-robot page, nothing
// under the API or /internal. `note` is the one-line llms.txt entry, `text` the page's full text in llms-full.txt,
// under the API or /internal. `note` is the one-line llms.txt entry, `text` the page's full text in llms-full.txt,
@@ -68,6 +69,16 @@ const LLMS_PAGES = [
@@ -68,6 +69,16 @@ const LLMS_PAGES = [
note: 'Drive a simulated rover in the browser, no account and no hardware.',
note: 'Drive a simulated rover in the browser, no account and no hardware.',
text: 'A rover simulated in the visitor\'s own browser, driven through the same OpenVibe.Bot panel a paired robot gets: a touch joystick, the keyboard (W A S D or the arrows, Space to stop) or a gamepad, with the same command shaping, deadman and latched e-stop. It has a front camera drawn from its position in a small arena, an ultrasonic distance sensor, three line sensors over a strip of yellow tape, a battery, RGB headlights and a horn. Nothing pressed there moves real hardware, and nothing needs an account. The page ends with the way to a real robot: add one, pair its computer with one pasted command, and the same panel drives it.',
text: 'A rover simulated in the visitor\'s own browser, driven through the same OpenVibe.Bot panel a paired robot gets: a touch joystick, the keyboard (W A S D or the arrows, Space to stop) or a gamepad, with the same command shaping, deadman and latched e-stop. It has a front camera drawn from its position in a small arena, an ultrasonic distance sensor, three line sensors over a strip of yellow tape, a battery, RGB headlights and a horn. Nothing pressed there moves real hardware, and nothing needs an account. The page ends with the way to a real robot: add one, pair its computer with one pasted command, and the same panel drives it.',
},
},
+ {
+ path: '/robots', title: 'Robots',
+ note: 'The robot directory: robots people list, watchable live, and the models Bot drives.',
+ text: 'The OpenVibe.Bot robot directory. Owners may list a robot; a listed robot has a public page where anyone can watch it live through its read-only panel, and a robot whose owner opened a queue lets signed-in visitors take timed turns at the controls. The page also lists every model Bot has a profile for, and, for a signed-in person, their own robots with the way to add one.',
+ },
+ {
+ path: '/models', title: 'Models',
+ note: 'Every robot model Bot drives, by how it connects.',
+ text: 'The robots OpenVibe.Bot drives, grouped by how they connect: simulated (the rover that runs in Bot and in the browser), on-board (the OpenVibe Node on the robot\'s own computer: the Adeept ADR036 4WD car and its mecanum build, the relay robot for any Linux board), bridge (the Node on a computer or phone next to the robot: Cozmo through PyCozmo, the LOOI desk robot over Bluetooth) and server-side (ONVIF pan-tilt-zoom cameras, nothing to install). Each model has a page with how it connects, its setup steps, its guide and the controls its panel gets.',
+ },
{
{
path: '/docs', title: 'Build for OpenVibe.Bot',
path: '/docs', title: 'Build for OpenVibe.Bot',
note: 'The build index: drivers and robot profiles.',
note: 'The build index: drivers and robot profiles.',
@@ -103,8 +114,9 @@ const LLMS_PAGES = [
@@ -103,8 +114,9 @@ const LLMS_PAGES = [
// bundle: the page imports the pinned ESM build OpenVibe.Live uses from esm.sh, and its viewer
// bundle: the page imports the pinned ESM build OpenVibe.Live uses from esm.sh, and its viewer
// signaling endpoint is OpenRestream's wss host (OpenRestream README "playback descriptor"). Exactly these two
// signaling endpoint is OpenRestream's wss host (OpenRestream README "playback descriptor"). Exactly these two
// sources, on the panel and embed pages only — the site-wide CSP (server/app.js) stays as it is.
// sources, on the panel and embed pages only — the site-wide CSP (server/app.js) stays as it is.
-const PANEL_SCRIPT_SRC = 'https://esm.sh';
-const PANEL_CONNECT_SRC = 'wss://ingest.openre.stream';
+// Plus Cloudflare's Web Analytics beacon, which the edge adds to every HTML page.
+const PANEL_SCRIPT_SRC = 'https://esm.sh https://static.cloudflareinsights.com';
+const PANEL_CONNECT_SRC = 'wss://ingest.openre.stream https://cloudflareinsights.com';
const PANEL_CSP = ["default-src 'self'", `script-src 'self' ${PANEL_SCRIPT_SRC}`, `connect-src 'self' ${PANEL_CONNECT_SRC}`,
const PANEL_CSP = ["default-src 'self'", `script-src 'self' ${PANEL_SCRIPT_SRC}`, `connect-src 'self' ${PANEL_CONNECT_SRC}`,
"frame-ancestors 'self'", "object-src 'none'", "base-uri 'self'"].join('; ');
"frame-ancestors 'self'", "object-src 'none'", "base-uri 'self'"].join('; ');
/** The embed's own CSP: the panel's sources plus the frame-ancestors allow-list. */
/** The embed's own CSP: the panel's sources plus the frame-ancestors allow-list. */
@@ -115,12 +127,21 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
@@ -115,12 +127,21 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
const r = express.Router();
const r = express.Router();
// The public front page is crawlable; sign-in, the API and every signed-in or per-robot page are not.
// 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/',
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')));
+ 'Disallow: /robots/new', '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));
// The sitemap lists every public GET page (openvibe-shared/seo); robots.txt already names it.
// The sitemap lists every public GET page (openvibe-shared/seo); robots.txt already names it.
- r.get('/sitemap.xml', (req, res) => {
- res.type('application/xml').send(seo.sitemapXml(PUBLIC_PAGES.map((p) => ({ loc: `${config.baseUrl}${p}`, lastmod: releaseLastmod }))));
+ r.get('/sitemap.xml', async (req, res, next) => {
+ try {
+ const pages = PUBLIC_PAGES.map((p) => ({ loc: `${config.baseUrl}${p}`, lastmod: releaseLastmod }));
+ if (domain) {
+ for (const row of await listProfiles(domain.db)) pages.push({ loc: `${config.baseUrl}/models/${row.profile.id}`, lastmod: releaseLastmod });
+ for (const robot of await domain.robots.listListed({ limit: 200 })) {
+ pages.push({ loc: `${config.baseUrl}/r/${robot.id}`, lastmod: new Date(robot.updated_at).toISOString() });
+ }
+ }
+ res.type('application/xml').send(seo.sitemapXml(pages));
+ } catch (e) { next(e); }
});
});
// /llms.txt and /llms-full.txt (llmstxt.org, openvibe-shared/seo): the public pages as a plain-text map for
// /llms.txt and /llms-full.txt (llmstxt.org, openvibe-shared/seo): the public pages as a plain-text map for
// language-model crawlers. Only the pages above; the signed-in pages, the API and /internal are not listed.
// language-model crawlers. Only the pages above; the signed-in pages, the API and /internal are not listed.
@@ -150,6 +171,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
@@ -150,6 +171,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
r.get('/panel/sim-world.js', asset('sim-world.js', 'application/javascript'));
r.get('/panel/sim-world.js', asset('sim-world.js', 'application/javascript'));
r.get('/panel/sim-view.js', asset('sim-view.js', 'application/javascript'));
r.get('/panel/sim-view.js', asset('sim-view.js', 'application/javascript'));
r.get('/panel/try-device.js', asset('try-device.js', 'application/javascript'));
r.get('/panel/try-device.js', asset('try-device.js', 'application/javascript'));
+ r.get('/directory.css', asset('directory.css', 'text/css'));
// /try: the real panel driving a rover simulated in the visitor's own browser (public/try-device.js), so anyone
// /try: the real panel driving a rover simulated in the visitor's own browser (public/try-device.js), so anyone
// can try it with no account and no hardware (plan T15 "a simulated robot in the browser"). Nothing reaches the
// can try it with no account and no hardware (plan T15 "a simulated robot in the browser"). Nothing reaches the
@@ -228,16 +250,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
@@ -228,16 +250,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
});
});
// The embed flag is web-only: domain.present.robot (and so /api/v1) does not carry it.
// The embed flag is web-only: domain.present.robot (and so /api/v1) does not carry it.
- const presentForPage = (row) => ({ ...domain.present.robot(row), embed_public: !!row.embed_public });
-
- async function robotsPage(req, res, me, extra = {}) {
- const robots = (await domain.robots.list(me.subject)).map(presentForPage);
- const profiles = (await listProfiles(domain.db)).map((p) => p.profile);
- // A kit's "Add this robot" arrives as ?profile=<id>: the add form opens with that model chosen.
- const asked = typeof req.query.profile === 'string' && profiles.some((p) => p.id === req.query.profile) ? req.query.profile : null;
- const values = extra.values || (asked ? { profile_id: asked } : {});
- res.type('html').send(renderRobotsPage({ robots, profiles, kits: kits.list(), ...extra, values }));
- }
+ const presentForPage = (row) => ({ ...domain.present.robot(row), embed_public: !!row.embed_public, listed: !!row.listed });
// The front page of openvibe.bot (server/web/home.js): public, indexable, with the OpenVibe Frame. Its two
// 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.
// actions differ by sign-in, so it is never stored by a shared cache.
@@ -248,7 +261,61 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
@@ -248,7 +261,61 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
res.type('html').send(renderHome({ config, signedIn: !!req.viewer }));
res.type('html').send(renderHome({ config, signedIn: !!req.viewer }));
});
});
- r.get('/robots', page(async (req, res) => robotsPage(req, res, requireUser(req))));
+ // ── The directory (plan T15 "a robot directory (/robots)"): public pages inside the OpenVibe Frame. ──
+ const catalogue = async () => (await listProfiles(domain.db)).map((row) => modelOf(row.profile));
+ const namesOf = async (subjects) => {
+ if (!identity || !subjects.length) return new Map();
+ try { return await identity.names([...new Set(subjects)]); } catch (e) { log.warn(`[Bot] directory names: ${e.message}`); return new Map(); }
+ };
+ const onlineOf = async (id) => { try { return !!(hub && (await hub.robotState(id)).online); } catch { return false; } };
+
+ r.get('/robots', frameCsp, page(async (req, res) => {
+ res.setHeader('Cache-Control', 'private, no-cache');
+ res.setHeader('Vary', 'Cookie');
+ const models = await catalogue();
+ const byId = new Map(models.map((m) => [m.id, m]));
+ const modelName = (row) => (byId.get(row.profile_id) || { name: row.profile_id.startsWith('local.') ? 'Its own profile' : row.profile_id }).name;
+ const rows = await domain.robots.listListed({ limit: 60 });
+ const names = await namesOf(rows.map((r) => r.owner_subject));
+ const listed = await Promise.all(rows.map(async (row) => ({
+ id: row.id, name: row.name, access_policy: row.access_policy, model_name: modelName(row),
+ owner: names.get(row.owner_subject) || null, online: await onlineOf(row.id),
+ })));
+ const mine = req.viewer ? (await domain.robots.list(req.viewer.subject)).map((row) => ({ id: row.id, name: row.name, listed: !!row.listed, model_name: modelName(row) })) : null;
+ res.type('html').send(renderDirectory({ config, listed, mine, models, signedIn: !!req.viewer }));
+ }));
+ r.get('/models', frameCsp, page(async (req, res) => {
+ res.setHeader('Cache-Control', 'public, max-age=300');
+ res.type('html').send(renderModels({ config, models: await catalogue() }));
+ }));
+ r.get('/models/:id', frameCsp, page(async (req, res) => {
+ const model = (await catalogue()).find((m) => m.id === req.params.id);
+ if (!model) throw new BotError(404, 'bot.profile_not_found', 'no such model');
+ res.setHeader('Cache-Control', 'public, max-age=300');
+ res.type('html').send(renderModel({ config, model, kit: kits.list().find((k) => k.profile_id === model.id) || null }));
+ }));
+ // A listed robot's public page; its owner and members see their own robot's page whether it is listed or not.
+ r.get('/r/:id', frameCsp, page(async (req, res) => {
+ res.setHeader('Cache-Control', 'private, no-cache');
+ res.setHeader('Vary', 'Cookie');
+ const robot = await domain.robots.get(req.params.id);
+ const role = robot && req.viewer ? await memberRole(robot, req.viewer) : null;
+ if (!robot || (!robot.listed && !role)) throw new BotError(404, 'bot.robot_not_found', 'no such robot');
+ const row = await getProfile(domain.db, robot.profile_id, robot.profile_version);
+ const model = row ? modelOf(row.profile) : { id: robot.profile_id, name: robot.profile_id };
+ const owner = (await namesOf([robot.owner_subject])).get(robot.owner_subject) || null;
+ const state = { online: await onlineOf(robot.id) };
+ res.type('html').send(renderRobotPage({ config, robot: { id: robot.id, name: robot.name, access_policy: robot.access_policy }, model, owner, state, role: role === 'queue' ? null : role }));
+ }));
+ // Adding a robot (the five-minute path's first step): name it, choose what it is, who may drive.
+ async function addPage(req, res, extra = {}) {
+ res.setHeader('Content-Security-Policy', HOME_CSP);
+ const models = (await catalogue()).filter((m) => !m.id.startsWith('local.'));
+ const asked = typeof req.query.profile === 'string' && models.some((m) => m.id === req.query.profile) ? req.query.profile : null;
+ const values = extra.values || (asked ? { profile_id: asked } : {});
+ res.type('html').send(renderAddRobot({ config, models, values, error: extra.error || null }));
+ }
+ r.get('/robots/new', page(async (req, res) => { requireUser(req); return addPage(req, res); }));
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) => {
const me = requireUser(req);
const me = requireUser(req);
@@ -259,7 +326,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
@@ -259,7 +326,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
try {
try {
({ robot, pairing } = await domain.robots.create({ owner: me.subject, name: values.name, profile_id: values.profile_id, access_policy: values.access_policy }));
({ robot, pairing } = await domain.robots.create({ owner: me.subject, name: values.name, profile_id: values.profile_id, access_policy: values.access_policy }));
} catch (e) {
} catch (e) {
- if (e.status === 422) { res.status(422); return robotsPage(req, res, me, { error: e.detail || e.code, values }); }
+ if (e.status === 422) { res.status(422); return addPage(req, res, { error: e.detail || e.code, values }); }
throw e;
throw e;
}
}
// A simulated robot needs no machine: straight to its panel. Otherwise the code create minted is shown here.
// A simulated robot needs no machine: straight to its panel. Otherwise the code create minted is shown here.
@@ -284,6 +351,19 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
@@ -284,6 +351,19 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
res.type('html').send(renderPairingPage({ robot: domain.present.robot(robot), pairing, profile: await profileOf(robot) }));
res.type('html').send(renderPairingPage({ robot: domain.present.robot(robot), pairing, profile: await profileOf(robot) }));
}));
}));
+ r.post('/robots/:id/listing', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {
+ const me = requireUser(req);
+ if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');
+ const robot = await domain.robots.get(req.params.id);
+ if (!robot) throw new BotError(404, 'bot.robot_not_found', 'no such robot');
+ if (robot.owner_subject !== me.subject) throw new BotError(403, 'bot.forbidden', 'only the owner may list a robot');
+ const sent = req.body && req.body.listed;
+ const value = Array.isArray(sent) ? sent[sent.length - 1] : sent;
+ if (value !== 'on' && value !== 'off') throw new BotError(422, 'bot.invalid_input', 'listed must be on or off');
+ await domain.robots.setListed(robot.id, value === 'on');
+ res.redirect(303, `/panel/${robot.id}`);
+ }));
+
r.post('/robots/:id/embed', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {
r.post('/robots/:id/embed', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {
const me = requireUser(req);
const me = requireUser(req);
if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');
if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');
new file mode 100644
new file mode 100644
@@ -0,0 +1,116 @@
@@ -0,0 +1,116 @@
+'use strict';
+// The robot directory (plan T15 "a robot directory (/robots)"): /robots is public (listed robots, the models, and a
+// signed-in person's own robots), an owner lists a robot from its panel, a listed robot has a public page (/r/:id)
+// that watches it through the embeddable panel, every model has a page, and adding a robot starts at /robots/new.
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/app');
+const { loadProfiles } = require('../server/profiles');
+
+(async () => {
+ const t = await boot();
+ const alex = t.network.newUser('alex');
+ const bob = t.network.newUser('bob');
+ const cookie = (user) => ({ Cookie: `ov_token=${t.network.signUser(user)}` });
+ const get = (p, user) => fetch(t.base + p, { redirect: 'manual', headers: user ? cookie(user) : {} });
+ const post = (p, user, body, headers = {}) => fetch(t.base + p, {
+ method: 'POST', redirect: 'manual', body,
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...(user ? cookie(user) : {}), ...headers },
+ });
+ const profiles = [...loadProfiles().values()];
+ const { robot } = await t.robot(alex, { name: 'Garage rover', profile_id: 'adeept.adr036', access_policy: 'queue' });
+
+ await check('GET /robots signed out: the models and the way in, no one\'s robots, inside the Frame', async () => {
+ const r = await get('/robots');
+ assert.strictEqual(r.status, 200);
+ assert.match(r.headers.get('cache-control'), /private/);
+ const html = await r.text();
+ assert.ok(html.includes('<h1>Robots</h1>') && html.includes('navbar-mount') && html.includes('/directory.css'));
+ for (const p of profiles) assert.ok(html.includes(`href="/models/${p.id}"`), p.id);
+ assert.ok(html.includes('href="/try"') && html.includes('Sign in and add a robot'));
+ assert.ok(!html.includes('Your robots') && !html.includes('Garage rover'), 'an unlisted robot is nobody else\'s business');
+ assert.ok(html.includes('No robot is listed yet.'));
+ });
+
+ await check('signed in: your robots, each marked listed or not, and Add a robot', async () => {
+ const html = await (await get('/robots', alex)).text();
+ assert.ok(html.includes('Your robots') && html.includes(`href="/panel/${robot.id}"`) && html.includes('Not listed'));
+ assert.ok(html.includes('href="/robots/new"'));
+ assert.ok(!(await (await get('/robots', bob)).text()).includes('Garage rover'));
+ });
+
+ await check('the owner lists it: it shows in the directory and on its own page, watchable by anyone', async () => {
+ const listing = `/robots/${robot.id}/listing`;
+ assert.strictEqual((await post(listing, bob, 'listed=off&listed=on')).status, 403, 'only the owner');
+ assert.strictEqual((await post(listing, alex, 'listed=off&listed=on', { Origin: 'https://evil.test' })).status, 403, 'no cross-site post');
+ assert.strictEqual((await post(listing, alex, 'listed=maybe')).status, 422);
+ const on = await post(listing, alex, 'listed=off&listed=on');
+ assert.strictEqual(on.status, 303);
+ assert.strictEqual(on.headers.get('location'), `/panel/${robot.id}`);
+ const row = await t.domain.robots.get(robot.id);
+ assert.ok(row.listed && row.listed_at && row.embed_public, 'listing makes it watchable');
+ const dir = await (await get('/robots')).text();
+ assert.ok(dir.includes(`href="/r/${robot.id}"`) && dir.includes('Garage rover') && dir.includes('Adeept ADR036 4WD Smart Car'));
+ assert.ok(dir.includes('Anyone signed in can take a timed turn.'));
+ const page = await get(`/r/${robot.id}`);
+ assert.strictEqual(page.status, 200);
+ const html = await page.text();
+ assert.ok(html.includes(`<iframe src="/panel/${robot.id}/embed"`), 'it watches through the embeddable panel');
+ assert.ok(html.includes(`href="/panel/${robot.id}"`) && html.includes('Take a turn'), 'a queue robot offers a turn');
+ assert.match(page.headers.get('content-security-policy'), /frame-src 'self'/);
+ assert.strictEqual((await get(`/panel/${robot.id}/embed`)).status, 200, 'and the frame answers a stranger');
+ const panel = await (await get(`/panel/${robot.id}`, alex)).text();
+ assert.ok(panel.includes(`action="/robots/${robot.id}/listing"`) && /name="listed" value="on" checked/.test(panel) && panel.includes(`href="/r/${robot.id}"`));
+ assert.ok((await (await get('/sitemap.xml')).text()).includes(`/r/${robot.id}</loc>`));
+ });
+
+ await check('unlisting takes it out; turning off watching unlists it; its page is then the owner\'s only', async () => {
+ await post(`/robots/${robot.id}/listing`, alex, 'listed=off');
+ let row = await t.domain.robots.get(robot.id);
+ assert.ok(!row.listed && row.embed_public, 'unlisting leaves embedding as it was');
+ assert.ok(!(await (await get('/robots')).text()).includes(`/r/${robot.id}`));
+ assert.strictEqual((await get(`/r/${robot.id}`)).status, 404);
+ assert.strictEqual((await get(`/r/${robot.id}`, alex)).status, 200, 'its owner still sees its page');
+ await post(`/robots/${robot.id}/listing`, alex, 'listed=on');
+ await post(`/robots/${robot.id}/embed`, alex, 'embed_public=off');
+ row = await t.domain.robots.get(robot.id);
+ assert.ok(!row.listed && !row.embed_public, 'a robot nobody may watch is not listed');
+ assert.strictEqual((await get('/r/rob_nothing')).status, 404);
+ });
+
+ await check('/models and /models/:id: every model by how it connects, its setup and its panel', async () => {
+ const r = await get('/models');
+ assert.strictEqual(r.status, 200);
+ assert.match(r.headers.get('cache-control'), /^public/);
+ const html = await r.text();
+ for (const label of ['Simulated', 'On-board', 'Bridge', 'Server-side']) assert.ok(html.includes(`>${label}</h2>`), label);
+ const cozmo = await (await get('/models/cozmo')).text();
+ assert.ok(cozmo.includes('<h1>Cozmo') && cozmo.includes('How it connects') && cozmo.includes('hardware-cozmo.md') && cozmo.includes('href="/robots/new?profile=cozmo"'));
+ const sim = await (await get('/models/sim.rover')).text();
+ assert.ok(sim.includes('href="/try"'), 'the simulated rover points at /try');
+ assert.strictEqual((await get('/models/nope')).status, 404);
+ const sitemap = await (await get('/sitemap.xml')).text();
+ for (const p of profiles) assert.ok(sitemap.includes(`/models/${p.id}</loc>`), p.id);
+ });
+
+ await check('/robots/new: signed in only; one choice per model, grouped by how it connects, the asked one chosen', async () => {
+ const out = await get('/robots/new');
+ assert.strictEqual(out.status, 302);
+ assert.strictEqual(out.headers.get('location'), `/auth/login?next=${encodeURIComponent('/robots/new')}`);
+ const html = await (await get('/robots/new', alex)).text();
+ for (const p of profiles) assert.ok(html.includes(`name="profile_id" value="${p.id}"`), p.id);
+ assert.match(html, /value="sim\.rover" checked/, 'the simulated rover by default');
+ assert.match(await (await get('/robots/new?profile=looi', alex)).text(), /value="looi" checked/);
+ const made = await post('/robots', alex, 'name=Desk+friend&profile_id=sim.rover&access_policy=private');
+ assert.strictEqual(made.status, 303, 'a simulated rover goes straight to its panel');
+ });
+
+ await check('robots.txt: the directory, models and robot pages are crawlable; adding and panels are not', async () => {
+ const txt = await (await get('/robots.txt')).text();
+ assert.ok(txt.includes('Disallow: /robots/new\n') && txt.includes('Disallow: /panel/\n'));
+ assert.ok(!/Disallow: \/robots\n|Disallow: \/r\/|Disallow: \/models/.test(txt));
+ });
+
+ t.app.locals.sim.stopAll();
+ await t.close();
+ done();
+})();
@@ -60,12 +60,10 @@ const { loadConfig, frameAncestors } = require('../server/config');
@@ -60,12 +60,10 @@ const { loadConfig, frameAncestors } = require('../server/config');
assert.strictEqual(on.status, 303);
assert.strictEqual(on.status, 303);
assert.strictEqual(on.headers.get('location'), `/panel/${robot.id}`);
assert.strictEqual(on.headers.get('location'), `/panel/${robot.id}`);
assert.strictEqual(await flag(robot.id), true);
assert.strictEqual(await flag(robot.id), true);
- for (const p of [`/panel/${robot.id}`, '/robots']) {
- const html = await (await get(p, alex)).text();
- assert.match(html, new RegExp(`action="/robots/${robot.id}/embed"`), p);
- assert.match(html, /name="embed_public" value="on" checked/, p);
- assert.match(html, /never the controls/, p);
- }
+ const html = await (await get(`/panel/${robot.id}`, alex)).text();
+ assert.match(html, new RegExp(`action="/robots/${robot.id}/embed"`));
+ assert.match(html, /name="embed_public" value="on" checked/);
+ assert.match(html, /never the controls/);
const r = await t.call('GET', `/api/v1/robots/${robot.id}`, { user: alex });
const r = await t.call('GET', `/api/v1/robots/${robot.id}`, { user: alex });
assert.ok(!r.text.includes('embed_public'));
assert.ok(!r.text.includes('embed_public'));
const off = await post(url, alex, { embed_public: 'off' });
const off = await post(url, alex, { embed_public: 'off' });
@@ -55,7 +55,7 @@ const { renderPanel } = require('../server/web/render');
@@ -55,7 +55,7 @@ const { renderPanel } = require('../server/web/render');
let ipN = 0;
let ipN = 0;
// Each watcher from its own address unless one is named (the test server trusts one proxy hop, as production).
// Each watcher from its own address unless one is named (the test server trusts one proxy hop, as production).
const watch = (ip = `10.0.0.${++ipN}`) => t.ws('/watch', { headers: { 'X-Forwarded-For': ip } });
const watch = (ip = `10.0.0.${++ipN}`) => t.ws('/watch', { headers: { 'X-Forwarded-For': ip } });
- const EMBED_CSP = "default-src 'self'; script-src 'self' https://esm.sh; connect-src 'self' wss://ingest.openre.stream; frame-ancestors 'self' https://openvibe.live https://www.openvibe.live; object-src 'none'; base-uri 'self'";
+ const EMBED_CSP = "default-src 'self'; script-src 'self' https://esm.sh https://static.cloudflareinsights.com; connect-src 'self' wss://ingest.openre.stream https://cloudflareinsights.com; frame-ancestors 'self' https://openvibe.live https://www.openvibe.live; object-src 'none'; base-uri 'self'";
const auditCount = async (id) => (await t.domain.audit.list(id, { limit: 100 })).length;
const auditCount = async (id) => (await t.domain.audit.list(id, { limit: 100 })).length;
const { robot: open } = await t.robot(alex, { name: 'Public rover' });
const { robot: open } = await t.robot(alex, { name: 'Public rover' });
@@ -74,7 +74,7 @@ const { renderPanel } = require('../server/web/render');
@@ -74,7 +74,7 @@ const { renderPanel } = require('../server/web/render');
assert.ok(t.hub.isOnline(`dev_sim_${open.id}`), 'the embed starts the simulator as the panel does');
assert.ok(t.hub.isOnline(`dev_sim_${open.id}`), 'the embed starts the simulator as the panel does');
// The rest of the site still refuses every frame.
// The rest of the site still refuses every frame.
assert.match((await get(`/panel/${open.id}`, alex)).headers.get('content-security-policy'), /frame-ancestors 'self';/);
assert.match((await get(`/panel/${open.id}`, alex)).headers.get('content-security-policy'), /frame-ancestors 'self';/);
- assert.match((await get('/robots', alex)).headers.get('content-security-policy'), /frame-ancestors 'self';/);
+ assert.match((await get('/robots', alex)).headers.get('content-security-policy'), /frame-ancestors 'none';/, 'the directory (a Frame page) refuses every frame');
});
});
await check('a robot that is not public: 403 with a link out and no robot data; unknown: 404; members keep their role', async () => {
await check('a robot that is not public: 403 with a link out and no robot data; unknown: 404; members keep their role', async () => {
@@ -47,8 +47,8 @@ const { boot, check, done } = require('./helpers/app');
@@ -47,8 +47,8 @@ const { boot, check, done } = require('./helpers/app');
});
});
await check('the rest of the site keeps its own CSP', async () => {
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'");
+ const r = await get('/pair/rob_nothing', alex);
+ assert.strictEqual(r.headers.get('content-security-policy'), "default-src 'self'; script-src 'self' https://static.cloudflareinsights.com; connect-src 'self' https://cloudflareinsights.com; frame-ancestors 'self'; object-src 'none'; base-uri 'self'");
});
});
await check('the vhost proxies / and /shared/ to the app; the legal pages are the app\'s, not the Sites files', async () => {
await check('the vhost proxies / and /shared/ to the app; the legal pages are the app\'s, not the Sites files', async () => {
@@ -100,18 +100,17 @@ const MINIMAL = {
@@ -100,18 +100,17 @@ const MINIMAL = {
assert.strictEqual(missing.json.code, 'bot.kit_not_found');
assert.strictEqual(missing.json.code, 'bot.kit_not_found');
});
});
- await check('the robots page offers "Get a robot": the kit, its parts, its guide and a start with its profile chosen', async () => {
+ await check('a kit\'s model page offers it: the parts, the kit\'s build guide, and a start with its profile chosen', async () => {
const alex = t.network.newUser('alex');
const alex = t.network.newUser('alex');
- const page = (q = '') => fetch(`${t.base}/robots${q}`, { redirect: 'manual', headers: { Cookie: `ov_token=${t.network.signUser(alex)}` } }).then((r) => r.text());
- const html = await page();
- assert.match(html, /<h2 id="kits-h">Get a robot<\/h2>/);
- assert.match(html, /Adeept ADR036 4WD Smart Car Kit for Raspberry Pi/);
- assert.match(html, /What you need/);
- assert.match(html, /href="https:\/\/www\.adeept\.com\/learn\/detail-97\.html" target="_blank" rel="noopener">Build guide/);
- assert.match(html, /href="\/robots\?profile=adeept\.adr036#add-robot">Add this robot/);
+ const page = (p) => fetch(`${t.base}${p}`, { redirect: 'manual', headers: { Cookie: `ov_token=${t.network.signUser(alex)}` } }).then((r) => r.text());
+ const html = await page('/models/adeept.adr036');
+ assert.match(html, /<h2>What you need<\/h2>/);
+ assert.match(html, /Adeept Robot HAT/);
+ assert.match(html, /href="https:\/\/www\.adeept\.com\/learn\/detail-97\.html" rel="noopener">The kit's build guide/);
+ assert.match(html, /href="\/robots\/new\?profile=adeept\.adr036">Add this robot/);
assert.doesNotMatch(html, /\(O30\)/, 'no internal plan codes in public copy');
assert.doesNotMatch(html, /\(O30\)/, 'no internal plan codes in public copy');
- assert.match(await page('?profile=adeept.adr036'), /<option value="adeept\.adr036" selected>/, 'the kit start chooses its profile');
- assert.doesNotMatch(await page('?profile=nope'), /<option value="nope"/);
+ assert.match(await page('/robots/new?profile=adeept.adr036'), /value="adeept\.adr036" checked/, 'the kit start chooses its profile');
+ assert.doesNotMatch(await page('/robots/new?profile=nope'), /value="nope"/);
});
});
await t.close();
await t.close();
@@ -148,10 +148,12 @@ const allowedFor = (profile, role) => [...(DEFAULT_ALLOW[role] || []).filter((k)
@@ -148,10 +148,12 @@ const allowedFor = (profile, role) => [...(DEFAULT_ALLOW[role] || []).filter((k)
assert.match(await (await get('/')).text(), /<h1>Drive your robot/);
assert.match(await (await get('/')).text(), /<h1>Drive your robot/);
const robots = await (await get('/robots.txt')).text();
const robots = await (await get('/robots.txt')).text();
assert.match(robots, /^Allow: \/\$$/m, 'the front page is crawlable');
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);
+ for (const p of ['/auth/', '/api/', '/robots/new', '/pair/', '/panel/', '/install']) assert.ok(robots.includes(`Disallow: ${p}\n`), p);
+ assert.ok(!robots.includes('Disallow: /robots\n'), 'the directory is public');
assert.match(robots, /^Sitemap: http:\/\/bot\.test\/sitemap\.xml$/m);
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']) {
+ assert.strictEqual((await get('/robots')).status, 200, 'the directory is public');
+ for (const p of ['/robots/new', '/panel/rob_x', '/pair/rob_x']) {
const r = await get(p);
const r = await get(p);
assert.strictEqual(r.status, 302, p);
assert.strictEqual(r.status, 302, p);
assert.strictEqual(r.headers.get('location'), `/auth/login?next=${encodeURIComponent(p)}`);
assert.strictEqual(r.headers.get('location'), `/auth/login?next=${encodeURIComponent(p)}`);
@@ -185,7 +187,7 @@ const allowedFor = (profile, role) => [...(DEFAULT_ALLOW[role] || []).filter((k)
@@ -185,7 +187,7 @@ const allowedFor = (profile, role) => [...(DEFAULT_ALLOW[role] || []).filter((k)
await check('the panel\'s CSP opens exactly esm.sh and OpenRestream\'s signaling; the rest of the site keeps its own', async () => {
await check('the panel\'s CSP opens exactly esm.sh and OpenRestream\'s signaling; the rest of the site keeps its own', async () => {
const csp = (await get(`/panel/${simId}`, alex)).headers.get('content-security-policy');
const csp = (await get(`/panel/${simId}`, alex)).headers.get('content-security-policy');
assert.strictEqual(csp,
assert.strictEqual(csp,
- "default-src 'self'; script-src 'self' https://esm.sh; connect-src 'self' wss://ingest.openre.stream; frame-ancestors 'self'; object-src 'none'; base-uri 'self'");
+ "default-src 'self'; script-src 'self' https://esm.sh https://static.cloudflareinsights.com; connect-src 'self' wss://ingest.openre.stream https://cloudflareinsights.com; frame-ancestors 'self'; object-src 'none'; base-uri 'self'");
for (const p of ['/robots', `/pair/${simId}`]) {
for (const p of ['/robots', `/pair/${simId}`]) {
const other = (await get(p, alex)).headers.get('content-security-policy') || '';
const other = (await get(p, alex)).headers.get('content-security-policy') || '';
assert.ok(!other.includes('esm.sh') && !other.includes('ingest.openre.stream'), `${p} was widened`);
assert.ok(!other.includes('esm.sh') && !other.includes('ingest.openre.stream'), `${p} was widened`);
@@ -328,7 +330,7 @@ const allowedFor = (profile, role) => [...(DEFAULT_ALLOW[role] || []).filter((k)
@@ -328,7 +330,7 @@ const allowedFor = (profile, role) => [...(DEFAULT_ALLOW[role] || []).filter((k)
await check('a bad form answers 422 with the form; a cross-site post is refused', async () => {
await check('a bad form answers 422 with the form; a cross-site post is refused', async () => {
const bad = await post('/robots', alex, { name: '', profile_id: 'sim.rover' });
const bad = await post('/robots', alex, { name: '', profile_id: 'sim.rover' });
assert.strictEqual(bad.status, 422);
assert.strictEqual(bad.status, 422);
- assert.match(await bad.text(), /class="error"/);
+ assert.match(await bad.text(), /class="dir-error" role="alert"/);
const unknown = await post('/robots', alex, { name: 'X', profile_id: 'nope' });
const unknown = await post('/robots', alex, { name: 'X', profile_id: 'nope' });
assert.strictEqual(unknown.status, 422);
assert.strictEqual(unknown.status, 422);
const cross = await post('/robots', alex, { name: 'X', profile_id: 'sim.rover' }, { Origin: 'https://evil.test' });
const cross = await post('/robots', alex, { name: 'X', profile_id: 'sim.rover' }, { Origin: 'https://evil.test' });
@@ -53,7 +53,7 @@ const { loadProfiles } = require('../server/profiles');
@@ -53,7 +53,7 @@ const { loadProfiles } = require('../server/profiles');
assert.ok(json, 'the profile rides along as data');
assert.ok(json, 'the profile rides along as data');
assert.deepStrictEqual(JSON.parse(json[1]), loadProfiles().get('sim.rover'));
assert.deepStrictEqual(JSON.parse(json[1]), loadProfiles().get('sim.rover'));
assert.ok(html.includes('<link rel="canonical" href="https://openvibe.bot/try">') && html.includes('<meta name="description"'));
assert.ok(html.includes('<link rel="canonical" href="https://openvibe.bot/try">') && html.includes('<meta name="description"'));
- assert.ok(html.includes('href="/robots#add-robot"'), 'the way on to a real robot');
+ assert.ok(html.includes('href="/robots/new"'), 'the way on to a real robot');
for (const s of scripts) {
for (const s of scripts) {
const js = await get(s);
const js = await get(s);
assert.strictEqual(js.status, 200, s);
assert.strictEqual(js.status, 200, s);