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)

OP OpenVibers committed 14 Oct 2026 · 3eb04e4a8b · 17 files, +577 −54

* Bot: a public robot directory (/robots, /models, /r/:id) and the add-a-robot page; owners list a robot from its panel

Plan T15 asks for "a robot directory (`/robots`): public robot pages (an owner may publish theirs), a catalogue of supported models", with a model picker listing every kind of robot Bot connects. Until now `/robots` was a sign-in-only list with one form, and no robot or model had a public page.

**The directory (`server/web/directory.js`, inside the OpenVibe Frame, plain markup)**

| Page | Who sees it | What it shows |
|---|---|---|
| `/robots` | anyone | The robots their owners list, each with its online state, model, owner (@username from the Network) and who may drive it.<br>Every model Bot drives.<br>Signed in, your own robots, marked listed or not, with Add a robot. |
| `/models`, `/models/:id` | anyone | Every model, grouped by how it connects (simulated, on-board, bridge, server-side).<br>Each model's page has its setup steps, its guide (the Node's hardware docs) and the controls its panel gets.<br>A kit's page also has its parts and build guide. |
| `/r/:id` | anyone, for a listed robot; otherwise its owner and members (others get 404) | The robot's public page.<br>It watches the robot live through the embeddable panel, framed by this site.<br>For a queue robot, a "Take a turn" button. |
| `/robots/new` | signed in | The first step of the five-minute path: name it, choose what it is from cards grouped by connection kind, choose who may drive.<br>A kit's "Add this robot" arrives with its model already chosen. |

**Listing**
- The owner switches it on the panel's new Directory card (`POST /robots/:id/listing`; migration `0009_robot_listed`: `robots.listed`, `listed_at`, and a partial index).
- Listing also turns on watching (`embed_public`), because a directory entry nobody can watch is no entry.
- Turning watching off unlists the robot.
- Owner-only, a same-origin form post, and counted against `bot.robot.manage`.

**Crawlers**
- `robots.txt` now allows `/robots`, `/models` and `/r/` and disallows `/robots/new`.
- The sitemap lists `/robots`, `/models`, every model page and every listed robot (with `lastmod`).
- `/llms.txt` describes the directory and the models.

**CSP**
- Cloudflare's edge adds its Web Analytics beacon to every HTML page. Bot's strict CSPs blocked it, which put a console error on `/try` and every panel in production.
- The site-wide, panel, embed and Frame CSPs now allow `static.cloudflareinsights.com` (script) and `cloudflareinsights.com` (connect), as Help, Actor, Food and the others do.

**Tests**
- New `test/directory.test.js`:
  - the signed-out and signed-in directory;
  - listing: owner only, no cross-site post, a bad value is refused;
  - it shows in the directory and on its page, and the embed answers a stranger;
  - unlisting, and turning watching off unlists;
  - `/models` and `/models/:id`;
  - `/robots/new`: signed in only, the asked model chosen;
  - the sitemap and robots.txt.
- `kits`, `panel`, `embed-panel`, `embed-flag`, `home` and `try-sim` follow the moves.
- The full suite passes: 34/34 test files.

**Checked in headless Chrome at 1280×900 and 390×844:**
- `/robots` signed out and signed in, `/models`, `/models/adeept.adr036`, `/robots/new` and `/r/:id` render with no horizontal overflow.
- `/r/:id` plays the rover's drawn camera inside the frame.

* STATUS.json: updated 2026-10-14

Changes

ddiff --git a/README.md b/README.md
iindex 84a7a67..1c92ca2 100644
---- a/README.md
++++ b/README.md
@@@ -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.
ddiff --git a/STATUS.json b/STATUS.json
iindex 018ccd6..91a0249 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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"
}
ddiff --git a/migrations/0009_robot_listed.sql b/migrations/0009_robot_listed.sql
nnew file mode 100644
iindex 0000000..1815f9d
---- /dev/null
++++ b/migrations/0009_robot_listed.sql
@@@ -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;
ddiff --git a/public/directory.css b/public/directory.css
nnew file mode 100644
iindex 0000000..d889b6f
---- /dev/null
++++ b/public/directory.css
@@@ -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; }
++}
ddiff --git a/server/app.js b/server/app.js
iindex f121e9e..330e315 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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());
ddiff --git a/server/domain/index.js b/server/domain/index.js
iindex 931eeec..0989511 100644
---- a/server/domain/index.js
++++ b/server/domain/index.js
@@@ -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 },
ddiff --git a/server/web/directory.js b/server/web/directory.js
nnew file mode 100644
iindex 0000000..1bc6394
---- /dev/null
++++ b/server/web/directory.js
@@@ -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 };
ddiff --git a/server/web/home.js b/server/web/home.js
iindex eff6752..e961cf2 100644
---- a/server/web/home.js
++++ b/server/web/home.js
@@@ -26,12 +26,12 @@ const DESCRIPTION = 'An open control panel for robots: pair a robot with one pas
const HOME_CSP = [
"default-src 'self'",
// The Frame's theme-loader, navbar and footer are served from /shared; the inline boot is the shell's.
-- "script-src 'self' 'unsafe-inline' https://openvibe.network",
++ "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'",
ddiff --git a/server/web/render.js b/server/web/render.js
iindex fcc97b3..9e0050b 100644
---- a/server/web/render.js
++++ b/server/web/render.js
@@@ -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>
ddiff --git a/server/web/routes.js b/server/web/routes.js
iindex 40b9a3c..0b348ae 100644
---- a/server/web/routes.js
++++ b/server/web/routes.js
@@@ -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, limi
const 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, limi
r.get('/panel/sim-world.js', asset('sim-world.js', 'application/javascript'));
r.get('/panel/sim-view.js', asset('sim-view.js', 'application/javascript'));
r.get('/panel/try-device.js', asset('try-device.js', 'application/javascript'));
++ 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, limi
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) => {
const me = requireUser(req);
@@@ -259,7 +326,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
try {
({ 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, limi
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) => {
const me = requireUser(req);
if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');
ddiff --git a/test/directory.test.js b/test/directory.test.js
nnew file mode 100644
iindex 0000000..d03393f
---- /dev/null
++++ b/test/directory.test.js
@@@ -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();
++})();
ddiff --git a/test/embed-flag.test.js b/test/embed-flag.test.js
iindex 7356161..297c3ce 100644
---- a/test/embed-flag.test.js
++++ b/test/embed-flag.test.js
@@@ -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' });
ddiff --git a/test/embed-panel.test.js b/test/embed-panel.test.js
iindex ee72f08..592e8ad 100644
---- a/test/embed-panel.test.js
++++ b/test/embed-panel.test.js
@@@ -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 () => {
ddiff --git a/test/home.test.js b/test/home.test.js
iindex b06da83..ead61d1 100644
---- a/test/home.test.js
++++ b/test/home.test.js
@@@ -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 () => {
ddiff --git a/test/kits.test.js b/test/kits.test.js
iindex be4a25f..5419547 100644
---- a/test/kits.test.js
++++ b/test/kits.test.js
@@@ -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&#39;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();
ddiff --git a/test/panel.test.js b/test/panel.test.js
iindex 9adcd28..0d1a939 100644
---- a/test/panel.test.js
++++ b/test/panel.test.js
@@@ -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' });
ddiff --git a/test/try-sim.test.js b/test/try-sim.test.js
iindex bd342a7..a039e09 100644
---- a/test/try-sim.test.js
++++ b/test/try-sim.test.js
@@@ -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);