openvibers/openvibe.bot Commits
Bot: community robots: anyone submits a profile with its driver and guide; an admin reviews, Bot signs it and it joins the catalogue (#92)
Changes
@@@ -16,6 +16,7 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato- 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.++- Community robots (`server/submissions.js`): anyone signed in submits a profile (id `community.<name>`, driver `plugin` for their own OpenVibe Node plugin, named in `mapping.plugin`, Contracts 0.160.0) with the driver's source, a setup guide and a licence at `/robots/submit`; `/robots/submissions` shows where each stands, and an OpenVibe admin approves or rejects (with a note) there. Approving signs the profile (Ed25519 over its canonical JSON, `BOT_PROFILE_SIGNING_KEY`) and puts it in the catalogue: `/models` "From the community", `/robots/new`, `GET /api/v1/profiles/:id` with its `signature`, and Bot's key at `GET /api/v1/profiles/signing-key`. Account deletion removes a person's unapproved submissions and keeps approved ones authorless.- A phone as the robot (`phone.camera`, driver `phone`): the pairing page shows a QR code (`server/web/qr.js`) that opens `/device/phone` with the robot and code in the URL fragment; the phone pairs over `POST /api/v1/pair` and its browser is the device (`public/phone-device.js`): `/device` with the credential offered as the subprotocol `ov.device.<credential>` (a browser cannot set `Authorization`), its camera published over WHIP to OpenRestream, `say` through speech synthesis, `display` faces and text full-screen, a torch and a chime, battery and tilt telemetry, and a Stop button that latches a local stop. Nothing to install.- `/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.@@@ -129,7 +130,7 @@ Set `BOT_ONVIF_CAMERAS` (JSON, unset by default) to the server-side cameras BotOpenVibe.Sites no longer generates `deploy/nginx/openvibe.bot.conf` (sites.json marks the vhost as owned by OpenVibe.Bot), and Bot serves nothing from a Sites checkout: the legal pages, `robots.txt`, `sitemap.xml`, `manifest.webmanifest` and the 404 page all come from the app. A Sites deploy leaves an installed vhost it no longer generates in place, so the current Sites vhost keeps serving openvibe.bot until Bot's first deploy installs this file over it; later Sites deploys never overwrite Bot's `/device` and `/control` routes.<!-- versions:start -->--- openvibe-contracts: v0.157.0++- openvibe-contracts: v0.160.0- openvibe-sdk: v0.44.0- openvibe-shared: v4.1.3<!-- versions:end -->@@@ -5,11 +5,11 @@"runtime": "node 22, express 4, ws; PostgreSQL 18 via openvibe-sdk/db (pg, or embedded PGlite in tests); Valkey optional; no build step","port": 4630,"domain": "openvibe.bot (www -> apex; /device and /control WebSockets; /install)",-- "contracts": "openvibe-contracts v0.157.0",++ "contracts": "openvibe-contracts v0.160.0","sdk": "openvibe-sdk v0.44.0","shared": "openvibe-shared v4.1.3","packages": [-- "openvibe-contracts v0.157.0",++ "openvibe-contracts v0.160.0","openvibe-sdk v0.44.0","openvibe-shared v4.1.3"],@@@ -45,6 +45,7 @@"/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","a phone as the robot (profile phone.camera, driver phone, Contracts 0.157.0): the pairing page's QR code (server/web/qr.js, qrcode-generator) opens /device/phone with the robot and code in the fragment; the phone pairs over POST /api/v1/pair and its browser is the device (public/phone-device.js): /device with the credential as the subprotocol ov.device.<credential>, the camera over WHIP to OpenRestream, say (speech synthesis), display (faces, text), torch, chime, battery and tilt telemetry, a local Stop (estop_state local_stop); /device/phone has its own CSP (the WHIP ingest origin) and Permissions-Policy camera=(self)",++ "community robots (server/submissions.js, migration 0010): submit at /robots/submit (id community.<name>, driver plugin + mapping.plugin, Contracts 0.160.0, https driver and guide links, licence), status at /robots/submissions, admin review there (approve signs Ed25519 over canonical JSON with BOT_PROFILE_SIGNING_KEY and inserts robot_profiles; reject needs a note); GET /api/v1/profiles/:id carries the signature, GET /api/v1/profiles/signing-key the public key; /models lists them under From the community with credits; in the ADR-033 export, and deletion drops unapproved ones and keeps approved ones authorless","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",nnew file mode 100644@@@ -0,0 +1,32 @@++-- phase: expand++--++-- Community robot profiles (plan T15 "people submit bots and guides and Bot connects them"): anyone signed in submits a++-- profile (its id under community.), with the driver's source, a setup guide and a licence; an OpenVibe admin reviews++-- it. An approved one is signed by Bot (Ed25519 over its canonical JSON, BOT_PROFILE_SIGNING_KEY) and joins the++-- catalogue (robot_profiles), so robots can be added with it. Additive only.++--++-- Account deletion (ADR-033): a person's pending and rejected submissions are deleted; an approved one stays in the++-- catalogue (robots may use it) with submitter_subject cleared.++++CREATE TABLE IF NOT EXISTS profile_submissions (++ id text PRIMARY KEY, -- sub_…++ submitter_subject text, -- usr_…; NULL once the account is deleted++ profile_id text NOT NULL, -- community.…++ profile_version integer NOT NULL,++ profile jsonb NOT NULL, -- validated bot.robot-profile@1++ driver_url text, -- the driver's source (https)++ guide_url text, -- the setup guide (https)++ license text,++ notes text,++ status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'rejected', 'withdrawn')),++ review_note text,++ reviewed_by text,++ reviewed_at timestamptz,++ signature text, -- base64 Ed25519 signature, on approval++ key_id text, -- which signing key made it++ created_at timestamptz NOT NULL,++ updated_at timestamptz NOT NULL++);++CREATE INDEX IF NOT EXISTS profile_submissions_status_idx ON profile_submissions (status, created_at);++CREATE INDEX IF NOT EXISTS profile_submissions_submitter_idx ON profile_submissions (submitter_subject);++CREATE UNIQUE INDEX IF NOT EXISTS profile_submissions_approved_one ON profile_submissions (profile_id, profile_version) WHERE status = 'approved';@@@ -13,7 +13,7 @@"dotenv": "^16.4.7","express": "^4.21.2","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.157.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.160.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.3","pg": "^8.23.0",@@@ -845,9 +845,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.157.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.157.0",-- "integrity": "sha512-GhcDBEdSZOxuKjO3ivN7lZlm685ULRABNkOIxSpJIRwv9qTuATcJe/PwjHfUlcr+VmZeJkd2eJZJl07yIuomdw==",++ "version": "0.160.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.160.0",++ "integrity": "sha512-1hhbfO3js3IRrCH2XDTy6TVo9aPaSqSangyBRq1U2WdMvoToeujiucERZnXQCW8kIYWM6R+Ow5M7jNGi7ySnwQ==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -25,7 +25,7 @@"dotenv": "^16.4.7","express": "^4.21.2","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.157.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.160.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.3","pg": "^8.23.0",@@@ -79,3 +79,16 @@.dir-row-actions { margin-left: 0; width: 100%; }.dir-live iframe { height: 86vh; }}++++/* Community robots (/robots/submit, /robots/submissions, community models). */++.dir-community { color: #2dd4bf; margin-left: 4px; }++.dir-card-head > span { display: inline-flex; gap: 4px; flex-wrap: wrap; justify-content: flex-end; }++.dir-field.dir-wide { max-width: none; }++.dir-form textarea { font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; padding: 10px 12px; border-radius: 10px; border: 1px solid var(--border, #1f2d47); background: var(--bg-primary, #0a0f1c); color: var(--text-primary, #e6edf7); resize: vertical; width: 100%; box-sizing: border-box; }++.dir-form textarea:focus, .dir-form input[type="url"]:focus { outline: 2px solid var(--accent, #3b82f6); outline-offset: 1px; }++.dir-form input[type="url"] { 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-sub { display: block; }++.dir-sub-body { padding: 0 18px 14px; }++.dir-sub-body form { margin: 8px 0 0; }++.dir-pre { max-height: 320px; overflow: auto; padding: 10px 12px; border-radius: 10px; background: var(--bg-primary, #0a0f1c); border: 1px solid var(--border, #1f2d47); font: 12.5px/1.45 ui-monospace, monospace; }++.dir-review .dir-field input { font: inherit; min-height: 40px; padding: 0 10px; border-radius: 8px; border: 1px solid var(--border, #1f2d47); background: var(--bg-primary, #0a0f1c); color: var(--text-primary, #e6edf7); }@@@ -28,6 +28,10 @@ const TABLES = [{ table: 'robot_operators', subject: 'added_by', file: null, erase: anonymize },{ table: 'live_conversions', subject: 'owner_subject', file: null },{ table: 'run_jobs', subject: 'subject', value: (usr) => [usr, `user:${usr}`], file: 'run-jobs.json', columns: ['id', 'class', 'state', 'wall_ms', 'exit_reason', 'created_at', 'finished_at'], erase: { keep: 'usage seconds reported to Billing' } },++ // Community robot profiles: the person's own submissions are exported; on deletion the ones still waiting, refused or++ // withdrawn go (extraErase) and an approved one stays in the catalogue, authorless (robots may use it).++ { table: 'profile_submissions', subject: 'submitter_subject', file: 'robot-submissions.json', columns: ['profile_id', 'profile_version', 'profile', 'driver_url', 'guide_url', 'license', 'notes', 'status', 'review_note', 'created_at'], order: 'created_at', erase: { keep: 'approved profiles stay in the catalogue, authorless (extraErase)' } },++ { table: 'profile_submissions', subject: 'reviewed_by', file: null, erase: anonymize },];async function extraExport(db, subject) {@@@ -39,6 +43,9 @@ async function extraExport(db, subject) {/** The account-data handle over Bot's domain (server/domain: robots.remove, devices.revoke). */function create({ domain, log = console } = {}) {async function extraErase(t, subjects, counts) {++ // A submission that never reached the catalogue is the person's alone; an approved one stays, without its author.++ counts.add(counts.erased, 'profile_submissions', await t.exec("DELETE FROM profile_submissions WHERE submitter_subject = ANY($1::text[]) AND status <> 'approved'", [subjects]));++ await t.exec("UPDATE profile_submissions SET submitter_subject = NULL WHERE submitter_subject = ANY($1::text[])", [subjects]);const robots = await t.many('SELECT id FROM robots WHERE owner_subject = ANY($1::text[])', [subjects]);const ids = robots.map((r) => r.id);if (ids.length) {@@@ -49,7 +49,7 @@ const CAP = {dispatch: 'bot.job.dispatch',};--function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config }) {++function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, submissions = null }) {const r = express.Router();r.use(limits.reads('bot.read'));r.use('/resources', resourceIndex.router({ db: domain.db, apiAuth }));@@@ -131,10 +131,18 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config }) {const rows = await listProfiles(domain.db);res.json({ profiles: rows.map((p) => p.profile) });}));++ // Bot's public key for community profiles' signatures (server/submissions.js): Ed25519 over the canonical JSON.++ r.get('/profiles/signing-key', wrap(async (req, res) => {++ const key = submissions && submissions.signingKey();++ if (!key) fail(404, 'bot.signing_unavailable', 'no profile signing key is configured');++ res.json({ key });++ }));++ // A community profile also carries the signature its approval made, and where its driver and guide are.r.get('/profiles/:id', wrap(async (req, res) => {const p = await getProfile(domain.db, req.params.id);if (!p) fail(404, 'bot.profile_not_found', 'no such profile');-- res.json({ profile: p.profile });++ const signature = submissions && p.id.startsWith('community.') ? await submissions.signatureFor(p.id, p.version) : null;++ res.json({ profile: p.profile, ...(signature ? { signature } : {}) });}));// The same validator the /docs/profiles form runs (server/profiles checkProfile): a profile in,// { valid, problems[] } out. Public, and rate-limited per address exactly as the form is.@@@ -41,6 +41,7 @@ const { createActorLimits } = require('./api/actor-limits');const { createSessionRoutes, viewerMiddleware } = require('./web/session');const { createWebRoutes } = require('./web/routes');const { HOME_CSP } = require('./web/home');++const { createSubmissions } = require('./submissions');const { inputError } = require('./util');const { createBotReadiness, registerBotGauges } = require('./observability');@@@ -75,6 +76,7 @@ function createApp(opts = {}) {const identity = opts.identity || createIdentity(config, { fetchImpl });const openre = opts.openre !== undefined ? opts.openre : createOpenRe(config, { fetchImpl });const domain = createDomain({ db, config, outbox, link: hub, nodes, openre, now, log });++ const submissions = createSubmissions({ db, config, now, log });hub.bindDomain(domain);// One jobs service for the device socket and the jobs API, so both see the same stdout rings.const jobs = createJobs({ db: () => domain.db, hub, usage, log, now });@@@ -127,7 +129,7 @@ function createApp(opts = {}) {release.mount(app, { registry: metrics.registry });const limits = createActorLimits({ config, valkey, now: opts.limitsNow || now, registry: metrics.registry, log });-- app.use('/api/v1', express.json({ limit: '64kb' }), (req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); }, apiAuth.middleware, v1Router({ domain, apiAuth, limits, hub, jobs, config }));++ app.use('/api/v1', express.json({ limit: '64kb' }), (req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); }, apiAuth.middleware, v1Router({ domain, apiAuth, limits, hub, jobs, config, submissions }));app.use('/auth', createSessionRoutes(config, userAuth, { fetchImpl }));// ── The legal pages: Terms, Privacy and DMCA on openvibe.bot itself (openvibe-shared/legal). ──// The document carries its own inline theme and OpenVibe Frame boot, which the strict site-wide CSP@@@ -136,7 +138,7 @@ function createApp(opts = {}) {{ const legal = require('openvibe-shared/legal');app.get(legal.PATHS, (req, res, next) => { res.setHeader('Content-Security-Policy', HOME_CSP); next(); }, legal.handler({ id: 'bot', service: 'bot', host: 'openvibe.bot', name: 'OpenVibe.Bot', profile: 'games' })); }app.use(viewerMiddleware(userAuth));-- app.use(createWebRoutes(config, { domain, sim, onvif, limits, identity, hub, log, releaseLastmod: release.full().released_at === release.full().booted_at ? undefined : release.full().released_at }));++ app.use(createWebRoutes(config, { domain, sim, onvif, limits, identity, hub, log, submissions, releaseLastmod: release.full().released_at === release.full().booted_at ? undefined : release.full().released_at }));app.use((req, res) => {if (req.path.startsWith('/api/') || req.path.startsWith('/internal/')) return http.sendProblem(res, 404, 'not_found', { ctx: req.ov });@@@ -155,7 +157,7 @@ function createApp(opts = {}) {return res.status(500).type('text/plain').send('Something went wrong\n');});-- Object.assign(app.locals, { config, db, valkey, domain, keys, outbox, usage, hub, jobs, sim, onvif, userAuth, metrics });++ Object.assign(app.locals, { config, db, valkey, domain, keys, outbox, usage, hub, jobs, sim, onvif, userAuth, metrics, submissions });return app;}@@@ -208,6 +208,11 @@ function loadConfig(env = process.env) {media: {whipBase: trim(env.BOT_WHIP_BASE || ''),},++ // Community robot profiles (server/submissions.js): the Ed25519 key an approval signs a profile with. Unset means++ // submissions are taken and reviewed but none can be approved (the review says why).++ profiles: {++ signingKey: env.BOT_PROFILE_SIGNING_KEY || '',++ },// Server-side ONVIF cameras (driver `onvif`, server/onvif): the hosts Bot may connect to, keyed by// robot id (one camera per robot), plus the timeout each request gets. Credentials are secret// references only (username_ref/password_ref), resolved when the request is built; the config never@@@ -50,7 +50,10 @@ const WIDGETS = new Set(['drive', 'pan-tilt', 'servo', 'lights', 'horn', 'speake/** Device drivers a mapping may name. `relay` is a Node plugin with no hardware map of its own. */// `looi`: the community's Generic LOOI driver, a Bluetooth bridge plugin (OpenVibe.Node docs/hardware-looi.md).// `phone`: a phone that is the device itself, from its browser (public/phone-device.js), no Node and nothing installed.--const DRIVERS = new Set(['pca9685', 'ads7830', 'cozmo', 'looi', 'onvif', 'sim', 'relay', 'phone']);++// `plugin`: a community OpenVibe Node plugin, named in mapping.plugin, that the robot's owner installs (Contracts 0.160.0;++// community profiles are reviewed and signed before robots can use them, server/submissions.js).++const DRIVERS = new Set(['pca9685', 'ads7830', 'cozmo', 'looi', 'onvif', 'sim', 'relay', 'phone', 'plugin']);++const PLUGIN_NAME = /^[a-z][a-z0-9_-]{1,40}$/;const ID_RE = /^[a-z][a-z0-9._-]{1,63}$/;const KINDS = new Set(['onboard', 'bridge', 'server']);@@@ -190,6 +193,7 @@ function validateProfile(p) {return w.command != null ? { ...w, command: validateWidgetCommand(p.id, w, commands) } : { ...w };});if (!p.mapping || typeof p.mapping !== 'object' || !DRIVERS.has(p.mapping.driver)) bad(`${p.id}: mapping.driver must be one of ${[...DRIVERS].join(', ')}`);++ if (p.mapping.driver === 'plugin' && !PLUGIN_NAME.test(String(p.mapping.plugin || ''))) bad(`${p.id}: a plugin driver names its Node plugin in mapping.plugin (lower case letters, digits, - and _)`);const l = p.limits && typeof p.limits === 'object' ? p.limits : {};const limits = {max_speed: number(l.max_speed) != null ? number(l.max_speed) : 1,nnew file mode 100644@@@ -0,0 +1,157 @@++'use strict';++++/**++ * Community robot profiles (plan T15 "people submit bots and guides and Bot connects them": submissions are reviewed++ * and signed before they run on anyone's Node). Anyone signed in submits a profile whose id is under `community.`,++ * with the driver's source, a setup guide and a licence; an OpenVibe admin approves or rejects it. Approving signs the++ * profile (Ed25519 over its canonical JSON, BOT_PROFILE_SIGNING_KEY) and puts it in the catalogue (robot_profiles), so++ * robots can be added with it; the signature and Bot's public key are published so a Node or anyone can check it.++ *++ * createSubmissions({ db, config, now })++ * submit(person, { profile, driver_url, guide_url, license, notes }) → the submission row (pending)++ * withdraw(id, person) / review(id, reviewer, { decision: 'approve'|'reject', note })++ * get(id), mine(subject), pending(), community() (community(): approved, newest first)++ * signatureFor(profileId, version) → { alg, key_id, value } | null++ * signingKey() → { alg, key_id, public_key_pem } | null (null when no key is configured)++ * canonicalJson(value) (sorted keys, no spaces: what is signed)++ */++const crypto = require('crypto');++const { checkProfile } = require('./profiles');++const { BotError, prefixedId, iso, text } = require('./util');++++const COMMUNITY_ID = /^community\.[a-z][a-z0-9_-]{1,40}$/;++const LICENSES = ['MIT', 'Apache-2.0', 'BSD-3-Clause', 'GPL-3.0-only', 'LGPL-3.0-only', 'MPL-2.0', 'CC0-1.0', 'other'];++const ALG = 'Ed25519';++++function fail(status, code, detail) { throw new BotError(status, code, detail); }++++/** JSON with every object's keys sorted and no whitespace: the exact bytes Bot signs. */++function canonicalJson(v) {++ if (Array.isArray(v)) return `[${v.map(canonicalJson).join(',')}]`;++ if (v && typeof v === 'object') return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${canonicalJson(v[k])}`).join(',')}}`;++ return JSON.stringify(v);++}++++/** BOT_PROFILE_SIGNING_KEY: an Ed25519 private key as PEM (literal \n allowed) or base64 of the PEM or of PKCS#8 DER. */++function loadKey(raw) {++ const s = String(raw || '').trim();++ if (!s) return null;++ let key;++ try {++ if (s.includes('BEGIN')) key = crypto.createPrivateKey(s.replace(/\\n/g, '\n'));++ else {++ const buf = Buffer.from(s, 'base64');++ const pem = buf.toString('utf8');++ key = pem.includes('BEGIN') ? crypto.createPrivateKey(pem) : crypto.createPrivateKey({ key: buf, format: 'der', type: 'pkcs8' });++ }++ } catch (e) { throw new Error(`BOT_PROFILE_SIGNING_KEY is not a private key: ${e.message}`); }++ if (key.asymmetricKeyType !== 'ed25519') throw new Error('BOT_PROFILE_SIGNING_KEY must be an Ed25519 key');++ const pub = crypto.createPublicKey(key);++ const der = pub.export({ type: 'spki', format: 'der' });++ return { key, keyId: crypto.createHash('sha256').update(der).digest('hex').slice(0, 16), publicPem: pub.export({ type: 'spki', format: 'pem' }) };++}++++function httpsUrl(v, name) {++ const s = typeof v === 'string' ? v.trim() : '';++ if (!s) return null;++ let u;++ try { u = new URL(s); } catch { fail(422, 'bot.invalid_input', `${name} is not a URL`); }++ if (u.protocol !== 'https:' || u.username || u.password || s.length > 300) fail(422, 'bot.invalid_input', `${name} must be an https link of at most 300 characters`);++ return u.href;++}++++function createSubmissions({ db, config = {}, now = () => Date.now(), log = console }) {++ // A key that does not load is logged and leaves approvals refused; it never stops the service.++ let signer = null;++ try { signer = loadKey(config.profiles && config.profiles.signingKey); } catch (e) { log.warn(`[Bot] ${e.message}; community profiles cannot be approved`); }++++ async function get(id) { return db.maybe('SELECT * FROM profile_submissions WHERE id = $1', [id]); }++ async function mine(subject) { return db.many('SELECT * FROM profile_submissions WHERE submitter_subject = $1 ORDER BY created_at DESC LIMIT 100', [subject]); }++ async function pending() { return db.many("SELECT * FROM profile_submissions WHERE status = 'pending' ORDER BY created_at LIMIT 200"); }++ async function community() {++ return db.many(`SELECT DISTINCT ON (profile_id) * FROM profile_submissions WHERE status = 'approved'++ ORDER BY profile_id, profile_version DESC`);++ }++++ async function submit(person, { profile, driver_url = null, guide_url = null, license = null, notes = null } = {}) {++ if (!person || !person.subject) fail(401, 'bot.sign_in', 'sign in to submit a robot');++ if (!profile || typeof profile !== 'object' || Array.isArray(profile)) fail(422, 'bot.invalid_input', 'the profile must be a JSON object');++ if (!COMMUNITY_ID.test(String(profile.id || ''))) fail(422, 'bot.invalid_input', 'a community profile\'s id is community.<name> (lower case letters, digits, - and _)');++ const checked = checkProfile(profile);++ if (!checked.valid) fail(422, 'bot.profile_invalid', checked.problems.join('; '));++ const p = checked.profile;++ const driver = httpsUrl(driver_url, 'the driver link');++ const guide = httpsUrl(guide_url, 'the setup guide link');++ if (!driver && p.mapping.driver === 'plugin') fail(422, 'bot.invalid_input', 'a robot driven by a community plugin needs a link to the plugin\'s source');++ const lic = license == null || license === '' ? null : String(license);++ if (lic && !LICENSES.includes(lic)) fail(422, 'bot.invalid_input', `licence must be one of ${LICENSES.join(', ')}`);++ const note = text(notes, 'notes', 2000) || null;++++ // The id is someone's once it is in the catalogue: only its original submitter sends a newer version of it.++ const owners = await db.many("SELECT DISTINCT submitter_subject FROM profile_submissions WHERE profile_id = $1 AND status = 'approved'", [p.id]);++ if (owners.some((o) => o.submitter_subject && o.submitter_subject !== person.subject)) fail(409, 'bot.profile_taken', 'that profile id belongs to someone else; choose another');++ const latest = await db.maybe('SELECT max(version) AS v FROM robot_profiles WHERE id = $1', [p.id]);++ if (latest && latest.v != null && p.version <= latest.v) fail(409, 'bot.profile_version', `version ${latest.v} is already in the catalogue; submit version ${latest.v + 1} or later`);++ const rival = await db.maybe("SELECT 1 FROM profile_submissions WHERE profile_id = $1 AND status = 'pending' AND submitter_subject <> $2", [p.id, person.subject]);++ if (rival) fail(409, 'bot.profile_taken', 'someone else has a submission with that id waiting for review; choose another');++++ const id = prefixedId('sub', now());++ const at = iso(now());++ await db.tx(async (t) => {++ // A newer submission of the same profile by the same person replaces their pending one.++ await t.query("UPDATE profile_submissions SET status = 'withdrawn', updated_at = $3 WHERE profile_id = $1 AND submitter_subject = $2 AND status = 'pending'", [p.id, person.subject, at]);++ await t.query(`INSERT INTO profile_submissions (id, submitter_subject, profile_id, profile_version, profile, driver_url, guide_url, license, notes, status, created_at, updated_at)++ VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7, $8, $9, 'pending', $10, $10)`,++ [id, person.subject, p.id, p.version, JSON.stringify(p), driver, guide, lic, note, at]);++ });++ return get(id);++ }++++ async function withdraw(id, person) {++ const row = await get(id);++ if (!row) fail(404, 'bot.submission_not_found', 'no such submission');++ if (!person || row.submitter_subject !== person.subject) fail(403, 'bot.forbidden', 'only its submitter may withdraw it');++ if (row.status !== 'pending') fail(409, 'bot.submission_closed', `it is already ${row.status}`);++ await db.query("UPDATE profile_submissions SET status = 'withdrawn', updated_at = $2 WHERE id = $1", [id, iso(now())]);++ return get(id);++ }++++ async function review(id, reviewer, { decision, note = null } = {}) {++ if (!reviewer || reviewer.role !== 'admin') fail(403, 'bot.forbidden', 'only an OpenVibe admin reviews community robots');++ const row = await get(id);++ if (!row) fail(404, 'bot.submission_not_found', 'no such submission');++ if (row.status !== 'pending') fail(409, 'bot.submission_closed', `it is already ${row.status}`);++ const why = text(note, 'note', 1000) || null;++ const at = iso(now());++ if (decision === 'reject') {++ if (!why) fail(422, 'bot.invalid_input', 'say why, so its submitter can fix it');++ await db.query("UPDATE profile_submissions SET status = 'rejected', review_note = $2, reviewed_by = $3, reviewed_at = $4, updated_at = $4 WHERE id = $1", [id, why, reviewer.subject, at]);++ return get(id);++ }++ if (decision !== 'approve') fail(422, 'bot.invalid_input', 'decision is approve or reject');++ if (!signer) fail(503, 'bot.signing_unavailable', 'no profile signing key is configured (BOT_PROFILE_SIGNING_KEY)');++ const profile = typeof row.profile === 'string' ? JSON.parse(row.profile) : row.profile;++ const checked = checkProfile(profile); // the rules may have moved since it was submitted++ if (!checked.valid) fail(422, 'bot.profile_invalid', checked.problems.join('; '));++ const signature = crypto.sign(null, Buffer.from(canonicalJson(checked.profile)), signer.key).toString('base64');++ await db.tx(async (t) => {++ const taken = await t.maybe('SELECT 1 FROM robot_profiles WHERE id = $1 AND version = $2', [row.profile_id, row.profile_version]);++ if (taken) fail(409, 'bot.profile_version', 'that version is already in the catalogue');++ await t.query('INSERT INTO robot_profiles (id, version, profile, created_at) VALUES ($1, $2, $3::jsonb, $4)', [row.profile_id, row.profile_version, JSON.stringify(checked.profile), at]);++ await t.query(`UPDATE profile_submissions SET status = 'approved', review_note = $2, reviewed_by = $3, reviewed_at = $4, updated_at = $4, signature = $5, key_id = $6++ WHERE id = $1`, [id, why, reviewer.subject, at, signature, signer.keyId]);++ });++ return get(id);++ }++++ async function signatureFor(profileId, version) {++ const row = await db.maybe("SELECT signature, key_id FROM profile_submissions WHERE profile_id = $1 AND profile_version = $2 AND status = 'approved'", [profileId, version]);++ return row && row.signature ? { alg: ALG, key_id: row.key_id, value: row.signature } : null;++ }++++ function signingKey() { return signer ? { alg: ALG, key_id: signer.keyId, public_key_pem: signer.publicPem } : null; }++++ return { submit, withdraw, review, get, mine, pending, community, signatureFor, signingKey };++}++++module.exports = { createSubmissions, canonicalJson, loadKey, COMMUNITY_ID, LICENSES };nnew file mode 100644@@@ -0,0 +1,53 @@++{++ "id": "community.my-robot",++ "version": 1,++ "name": "My robot",++ "vendor": "Me",++ "kind": "onboard",++ "description": "What it is, what it drives, and what it needs.",++ "capabilities": [++ "drive.differential",++ "battery"++ ],++ "mapping": {++ "driver": "plugin",++ "plugin": "my_robot"++ },++ "commands": {++ "drive": {++ "axes": {++ "throttle": [++ -1,++ 1++ ],++ "steer": [++ -1,++ 1++ ]++ }++ }++ },++ "widgets": [++ {++ "type": "drive",++ "capability": "drive.differential",++ "label": "Drive",++ "order": 1,++ "command": {++ "kind": "drive"++ }++ },++ {++ "type": "battery",++ "capability": "battery",++ "label": "Battery",++ "order": 2++ }++ ],++ "limits": {++ "max_speed": 0.6,++ "max_turn": 0.6,++ "max_command_ms": 300,++ "heartbeat_ms": 1000++ }++}@@@ -82,19 +82,26 @@ const CAPABILITY_WORDS = {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) {++/**++ * A catalogue profile as a model. `community` is the approved submission that put a community profile in the catalogue++ * ({ by, driver_url, guide_url, license, key_id }); its guide becomes the model's guide.++ */++function modelOf(profile, community = null) {const extra = MODELS[profile.id] || {};const connect = extra.connect || (profile.kind === 'bridge' ? 'bridge' : profile.kind === 'server' ? 'server' : 'onboard');++ const guide = extra.guide || (community && community.guide_url ? { href: community.guide_url, label: 'Setup guide' } : null);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,++ connect, order: extra.order != null ? extra.order : community ? 200 : 100, setup: extra.setup || [], guide,capabilities: (profile.capabilities || []).filter((c) => CAPABILITY_WORDS[c]), widgets: profile.widgets || [], profile,++ community,};}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 communityBadge = (m) => (m.community ? ' <span class="dir-badge dir-community">Community</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) {@@@ -109,7 +116,7 @@ function shell({ config, path, title, description, body }) {}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>`++ 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><span>${badge(m.connect)}${communityBadge(m)}</span></span>`+ `<span class="dir-card-text">${esc(short(m.description))}</span></a>${chips(m.capabilities.slice(0, 6))}</li>`;}@@@ -139,20 +146,24 @@ ${listed.length ? `<ul class="dir-grid">${listed.map(robotCard).join('')}</ul>`<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>`;++<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, then submit it so anyone can add one.</p>++<p class="dir-actions">${link('/robots/submit', 'Submit a robot', 'dir-btn dir-primary')} ${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', 'phone', 'onboard', 'bridge', 'server'].map((k) => [k, sortModels(models).filter((m) => m.connect === k)]).filter(([, ms]) => ms.length);++ const official = models.filter((m) => !m.community);++ const community = sortModels(models.filter((m) => m.community));++ const groups = ['browser', 'phone', 'onboard', 'bridge', 'server'].map((k) => [k, sortModels(official).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>`;++${community.length ? `<section class="dir-sec" aria-labelledby="g-community"><h2 id="g-community">From the community</h2><p class="muted">Profiles people submitted and an OpenVibe admin reviewed. Each one is signed by OpenVibe.Bot; its driver is the submitter's.</p>++<ul class="dir-grid">${community.map(modelCard).join('')}</ul></section>` : ''}++<section class="dir-sec"><h2>Not here?</h2><p class="muted">Write a profile for it and check it with the validator, then submit it: once it is reviewed, anyone can add a robot with it. A driver for its hardware runs under the OpenVibe Node.</p>++<p class="dir-actions">${link('/robots/submit', 'Submit a robot', 'dir-btn dir-primary')} ${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 });}@@@ -165,7 +176,7 @@ ${kit.build_guide_url ? `<p>${link(kit.build_guide_url, 'The kit\'s build guide'? `${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>++<header class="dir-hero"><h1>${esc(model.name)}</h1><p>${badge(model.connect)}${communityBadge(model)}${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>@@@ -173,6 +184,10 @@ ${model.setup.length ? `<ol>${model.setup.map((s) => `<li>${esc(s)}</li>`).join(<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}++${model.community ? `<section class="dir-sec card"><h2>From the community</h2><p>Submitted by ${esc(ownerName(model.community.by))} and reviewed by an OpenVibe admin.${model.community.license ? ` Licence: ${esc(model.community.license)}.` : ''}</p>++<ul>${model.community.driver_url ? `<li>Driver: ${link(model.community.driver_url, model.community.driver_url)}</li>` : ''}${model.community.guide_url ? `<li>Setup guide: ${link(model.community.guide_url, model.community.guide_url)}</li>` : ''}++<li>Signed by OpenVibe.Bot (Ed25519, key <code>${esc(model.community.key_id || '')}</code>): ${link(`/api/v1/profiles/${model.id}`, 'the profile and its signature')} · ${link('/api/v1/profiles/signing-key', 'the public key')}</li></ul>++<p class="muted">Install the driver from its source on the robot's computer; OpenVibe.Bot never runs it.</p></section>` : ''}<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 });}@@@ -215,4 +230,54 @@ ${groups.map(([title, ms]) => `<p class="dir-group">${esc(title)}</p><div class=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 };++// ── GET /robots/submit, /robots/submissions ────────────────────────────────────────────────────────++// The profile the submit form starts with (a JSON file: its robot capability names are not Network capabilities).++const TEMPLATE = require('./community-template.json');++const STATUS_WORDS = { pending: 'Waiting for review', approved: 'Approved', rejected: 'Not approved', withdrawn: 'Withdrawn' };++++function renderSubmit({ config, licenses = [], values = {}, error = null }) {++ const profile = values.profile != null ? values.profile : JSON.stringify(TEMPLATE, null, 2);++ const body = `<p class="dir-crumb">${link('/robots', 'Robots')} / ${link('/models', 'Models')}</p>++<header class="dir-hero"><h1>Submit a robot</h1><p class="muted">Describe your robot with a profile and point at its driver. An OpenVibe admin reviews it; once it is approved, OpenVibe.Bot signs it, it appears under <b>From the community</b>, and anyone can add a robot with it. ${link('/docs/profiles', 'How profiles work')} · ${link('/docs/drivers', 'Write a driver')}</p></header>++<form class="dir-form" method="post" action="/robots/submit">++${error ? `<p class="dir-error" role="alert">${esc(error)}</p>` : ''}++<label class="dir-field dir-wide"><span class="dir-field-label">Profile (bot.robot-profile@1)</span><textarea name="profile" rows="18" spellcheck="false" required>${esc(profile)}</textarea>++<span class="muted dir-note">Its id is <code>community.<name></code>. A robot run by your own Node plugin uses <code>"driver": "plugin"</code> and names it in <code>"plugin"</code>.</span></label>++<label class="dir-field"><span class="dir-field-label">Driver source (https)</span><input type="url" name="driver_url" maxlength="300" placeholder="https://github.com/you/your-driver" value="${esc(values.driver_url || '')}"></label>++<label class="dir-field"><span class="dir-field-label">Setup guide (https)</span><input type="url" name="guide_url" maxlength="300" placeholder="https://openvibe.wiki/…" value="${esc(values.guide_url || '')}"></label>++<label class="dir-field"><span class="dir-field-label">Driver licence</span><select name="license"><option value="">Choose…</option>${licenses.map((l) => `<option value="${esc(l)}"${l === values.license ? ' selected' : ''}>${esc(l)}</option>`).join('')}</select></label>++<label class="dir-field dir-wide"><span class="dir-field-label">Notes for the reviewer</span><textarea name="notes" rows="4" maxlength="2000">${esc(values.notes || '')}</textarea></label>++<p><button type="submit" class="dir-btn dir-primary">Submit for review</button></p>++</form>`;++ return shell({ config, path: '/robots/submit', title: 'Submit a robot', description: 'Submit a community robot profile to OpenVibe.Bot.', body });++}++++function submissionRow(sub, { review = false, by = null } = {}) {++ const p = typeof sub.profile === 'string' ? JSON.parse(sub.profile) : sub.profile;++ const head = `<span class="dir-card-head"><b>${esc(p.name)} <code>${esc(sub.profile_id)}</code> v${esc(sub.profile_version)}</b><span class="dir-pill">${esc(STATUS_WORDS[sub.status] || sub.status)}</span></span>`;++ const links = [sub.driver_url && link(sub.driver_url, 'driver'), sub.guide_url && link(sub.guide_url, 'guide'), sub.license && esc(sub.license)].filter(Boolean).join(' · ');++ const note = sub.review_note ? `<p class="muted">Reviewer: ${esc(sub.review_note)}</p>` : '';++ const done = sub.status === 'approved' ? `<p>${link(`/models/${sub.profile_id}`, 'Its model page')}</p>` : '';++ const withdraw = !review && sub.status === 'pending' ? `<form method="post" action="/robots/submissions/${esc(sub.id)}/withdraw"><button type="submit" class="dir-btn">Withdraw</button></form>` : '';++ const decide = review ? `<details><summary>The profile</summary><pre class="dir-pre">${esc(JSON.stringify(p, null, 2))}</pre></details>++${sub.notes ? `<p class="muted">Notes: ${esc(sub.notes)}</p>` : ''}++<form class="dir-review" method="post" action="/robots/submissions/${esc(sub.id)}/review">++<label class="dir-field dir-wide"><span class="dir-field-label">Note (needed to reject)</span><input type="text" name="note" maxlength="1000"></label>++<p class="dir-actions"><button type="submit" name="decision" value="approve" class="dir-btn dir-primary">Approve and sign</button> <button type="submit" name="decision" value="reject" class="dir-btn">Reject</button></p>++</form>` : '';++ return `<li class="dir-card dir-sub"><div class="dir-card-link">${head}<span class="dir-card-text">${review ? `From ${esc(ownerName(by))} · ` : ''}${links || 'no links'}</span></div><div class="dir-sub-body">${note}${done}${withdraw}${decide}</div></li>`;++}++++function renderSubmissions({ config, mine = [], pending = null, names = new Map(), signing = null }) {++ const body = `<p class="dir-crumb">${link('/robots', 'Robots')} / ${link('/models', 'Models')}</p>++<header class="dir-hero"><h1>Community robots</h1><p class="muted">Your submitted profiles and where each one stands. ${link('/robots/submit', 'Submit another')}</p></header>++<section class="dir-sec" aria-labelledby="mine-h"><h2 id="mine-h">Yours</h2>++${mine.length ? `<ul class="dir-list">${mine.map((s) => submissionRow(s)).join('')}</ul>` : '<p class="muted">Nothing submitted yet.</p>'}</section>++${pending ? `<section class="dir-sec" aria-labelledby="queue-h"><h2 id="queue-h">Waiting for review</h2>++${signing ? '' : '<p class="dir-error" role="alert">No profile signing key is configured, so nothing can be approved yet (BOT_PROFILE_SIGNING_KEY).</p>'}++<p class="muted">Check the profile's commands and limits are safe for the hardware it names, that its driver's source is public and matches, and that the guide is a real guide. Approving signs it and puts it in the catalogue.</p>++${pending.length ? `<ul class="dir-list">${pending.map((s) => submissionRow(s, { review: true, by: names.get(s.submitter_subject) })).join('')}</ul>` : '<p class="muted">The queue is empty.</p>'}</section>` : ''}`;++ return shell({ config, path: '/robots/submissions', title: 'Community robots', description: 'Community robot profiles on OpenVibe.Bot.', body });++}++++module.exports = { renderSubmit, renderSubmissions, renderDirectory, renderModels, renderModel, renderRobotPage, renderAddRobot, modelOf, sortModels, MODELS, CONNECT, CAPABILITY_WORDS };@@@ -40,7 +40,8 @@ const { renderPanel, renderEmbedRefused, renderDrivePage, renderPairingPage, renconst { 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 { renderDirectory, renderModels, renderModel, renderRobotPage, renderAddRobot, renderSubmit, renderSubmissions, modelOf } = require('./directory');++const { LICENSES } = require('../submissions');const PUBLIC = path.join(__dirname, '..', '..', 'public');const HOLD_RESEND_MS = 150;@@@ -123,11 +124,11 @@ const PANEL_CSP = ["default-src 'self'", `script-src 'self' ${PANEL_SCRIPT_SRC}`const embedCsp = (config) => ["default-src 'self'", `script-src 'self' ${PANEL_SCRIPT_SRC}`, `connect-src 'self' ${PANEL_CONNECT_SRC}`,`frame-ancestors ${frameAncestors(config)}`, "object-src 'none'", "base-uri 'self'"].join('; ');--function createWebRoutes(config, { domain = null, sim = null, onvif = null, limits = null, identity = null, hub = null, log = console, releaseLastmod } = {}) {++function createWebRoutes(config, { domain = null, sim = null, onvif = null, limits = null, identity = null, hub = null, log = console, submissions = null, releaseLastmod } = {}) {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/new', 'Disallow: /pair/', 'Disallow: /panel/', 'Disallow: /install', `Sitemap: ${config.baseUrl}/sitemap.xml`, ''].join('\n')));++ 'Disallow: /robots/new', 'Disallow: /robots/submit', 'Disallow: /robots/submissions', '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.@@@ -280,7 +281,13 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi});// ── 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));++ // Every catalogue profile as a model; a community one carries its approved submission (who, driver, guide, key).++ const catalogue = async () => {++ const approved = submissions ? await submissions.community() : [];++ const names = approved.length ? await namesOf(approved.map((s) => s.submitter_subject).filter(Boolean)) : new Map();++ const by = new Map(approved.map((s) => [s.profile_id, { by: names.get(s.submitter_subject) || null, driver_url: s.driver_url, guide_url: s.guide_url, license: s.license, key_id: s.key_id }]));++ return (await listProfiles(domain.db)).map((row) => modelOf(row.profile, by.get(row.profile.id) || null));++ };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(); }@@@ -335,6 +342,57 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi}r.get('/robots/new', page(async (req, res) => { requireUser(req); return addPage(req, res); }));++ // ── Community robots (server/submissions.js): submit a profile, see where it stands; admins review. ──++ const submitLimit = limits ? [(req, res, next) => { if (req.viewer && !req.principal) req.principal = req.viewer; next(); }, limits('bot.profile.submit', { minute: 20, hour: 60 })] : [];++ const isReviewer = (me) => !!(me && me.role === 'admin');++ r.get('/robots/submit', page(async (req, res) => {++ requireUser(req);++ res.setHeader('Content-Security-Policy', HOME_CSP);++ res.type('html').send(renderSubmit({ config, licenses: LICENSES }));++ }));++ r.post('/robots/submit', ...submitLimit, express.urlencoded({ extended: false, limit: '48kb' }), page(async (req, res) => {++ const me = requireUser(req);++ if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');++ if (!submissions) throw new BotError(503, 'bot.unavailable', 'submissions are not available');++ const b = req.body || {};++ const values = { profile: String(b.profile || ''), driver_url: String(b.driver_url || ''), guide_url: String(b.guide_url || ''), license: String(b.license || ''), notes: String(b.notes || '') };++ let profile;++ try { profile = JSON.parse(values.profile); } catch (e) {++ res.status(422).setHeader('Content-Security-Policy', HOME_CSP);++ return res.type('html').send(renderSubmit({ config, licenses: LICENSES, values, error: `The profile is not valid JSON: ${e.message}` }));++ }++ try {++ await submissions.submit(me, { profile, driver_url: values.driver_url, guide_url: values.guide_url, license: values.license, notes: values.notes });++ } catch (e) {++ if (!e.status || e.status >= 500 || e.status === 401) throw e;++ res.status(e.status).setHeader('Content-Security-Policy', HOME_CSP);++ return res.type('html').send(renderSubmit({ config, licenses: LICENSES, values, error: e.detail || e.code }));++ }++ return res.redirect(303, '/robots/submissions');++ }));++ r.get('/robots/submissions', page(async (req, res) => {++ const me = requireUser(req);++ if (!submissions) throw new BotError(503, 'bot.unavailable', 'submissions are not available');++ res.setHeader('Content-Security-Policy', HOME_CSP);++ const pending = isReviewer(me) ? await submissions.pending() : null;++ const names = pending && pending.length ? await namesOf(pending.map((s) => s.submitter_subject).filter(Boolean)) : new Map();++ res.type('html').send(renderSubmissions({ config, mine: await submissions.mine(me.subject), pending, names, signing: submissions.signingKey() }));++ }));++ r.post('/robots/submissions/:id/review', ...manage, express.urlencoded({ extended: false, limit: '4kb' }), page(async (req, res) => {++ const me = requireUser(req);++ if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');++ if (!submissions) throw new BotError(503, 'bot.unavailable', 'submissions are not available');++ await submissions.review(req.params.id, me, { decision: String(req.body.decision || ''), note: req.body.note });++ return res.redirect(303, '/robots/submissions');++ }));++ r.post('/robots/submissions/:id/withdraw', ...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');++ if (!submissions) throw new BotError(503, 'bot.unavailable', 'submissions are not available');++ await submissions.withdraw(req.params.id, me);++ return res.redirect(303, '/robots/submissions');++ }));++r.post('/robots', ...manage, express.urlencoded({ extended: false, limit: '8kb' }), 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,126 @@++'use strict';++// Community robot profiles (plan T15: "people submit bots and guides and Bot connects them", reviewed and signed before++// they run): submit at /robots/submit, see it at /robots/submissions, an admin approves (Bot signs it, Ed25519 over its++// canonical JSON) or rejects with a note; an approved profile is in the catalogue (/models "From the community",++// /robots/new, the API with its signature and Bot's public key) and robots can be added with it.++const assert = require('assert');++const crypto = require('crypto');++const { boot, check, done } = require('./helpers/app');++const { canonicalJson, loadKey } = require('../server/submissions');++++const { privateKey } = crypto.generateKeyPairSync('ed25519');++const KEY = Buffer.from(privateKey.export({ type: 'pkcs8', format: 'pem' })).toString('base64');++++const PROFILE = {++ id: 'community.ballbot', version: 1, name: 'Ballbot', vendor: 'Mia', kind: 'onboard',++ description: 'A self-balancing ball robot on an ESP32, driven by a community Node plugin.',++ capabilities: ['drive.differential', 'battery'],++ mapping: { driver: 'plugin', plugin: 'ballbot' },++ commands: { drive: { axes: { throttle: [-1, 1], steer: [-1, 1] } } },++ widgets: [++ { type: 'drive', capability: 'drive.differential', label: 'Drive', order: 1, command: { kind: 'drive' } },++ { type: 'battery', capability: 'battery', label: 'Battery', order: 2 },++ ],++ limits: { max_speed: 0.5, max_turn: 0.6, max_command_ms: 300, heartbeat_ms: 1000 },++};++++(async () => {++ await check('canonical JSON sorts keys at every depth; the key loads from base64 PEM, raw PEM with \\n, or refuses', () => {++ assert.strictEqual(canonicalJson({ b: 1, a: { d: [2, { y: 1, x: 2 }], c: 'é' } }), '{"a":{"c":"é","d":[2,{"x":2,"y":1}]},"b":1}');++ const k = loadKey(KEY);++ assert.match(k.keyId, /^[0-9a-f]{16}$/);++ assert.strictEqual(loadKey(privateKey.export({ type: 'pkcs8', format: 'pem' }).replace(/\n/g, '\\n')).keyId, k.keyId);++ assert.strictEqual(loadKey(''), null);++ assert.throws(() => loadKey('bm90IGEga2V5'), /not a private key/);++ const rsa = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey.export({ type: 'pkcs8', format: 'pem' });++ assert.throws(() => loadKey(rsa), /Ed25519/);++ });++++ const t = await boot({ env: { BOT_PROFILE_SIGNING_KEY: KEY } });++ const mia = t.network.newUser('mia');++ const sam = t.network.newUser('sam');++ const ada = t.network.newUser('ada');++ ada.role = 'admin';++ const cookie = (u) => ({ Cookie: `ov_token=${t.network.signUser(u)}` });++ const get = (p, u) => fetch(t.base + p, { redirect: 'manual', headers: u ? cookie(u) : {} });++ const post = (p, u, form, headers = {}) => fetch(t.base + p, {++ method: 'POST', redirect: 'manual', body: new URLSearchParams(form).toString(),++ headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...(u ? cookie(u) : {}), ...headers },++ });++ const submit = (u, profile, extra = {}, headers = {}) => post('/robots/submit', u, { profile: typeof profile === 'string' ? profile : JSON.stringify(profile), driver_url: 'https://github.com/mia/ballbot-driver', guide_url: 'https://openvibe.wiki/robots/ballbot', license: 'MIT', notes: 'Tested on two boards.', ...extra }, headers);++++ await check('/robots/submit: signed in only; a profile template; refusals come back on the form', async () => {++ assert.strictEqual((await get('/robots/submit')).status, 302);++ const form = await (await get('/robots/submit', mia)).text();++ assert.ok(form.includes('name="profile"') && form.includes('community.my-robot') && form.includes('<option value="MIT"'));++ const bad = await submit(mia, '{ not json');++ assert.strictEqual(bad.status, 422);++ assert.match(await bad.text(), /not valid JSON/);++ const notCommunity = await submit(mia, { ...PROFILE, id: 'ballbot' });++ assert.strictEqual(notCommunity.status, 422);++ assert.match(await notCommunity.text(), /community\.<name>|community\.<name>/);++ const noSource = await submit(mia, PROFILE, { driver_url: '' });++ assert.strictEqual(noSource.status, 422, 'a plugin-driven robot needs its plugin\'s source');++ const http = await submit(mia, PROFILE, { driver_url: 'http://example.test/x' });++ assert.strictEqual(http.status, 422, 'https only');++ const broken = await submit(mia, { ...PROFILE, mapping: { driver: 'plugin' } });++ assert.strictEqual(broken.status, 422, 'a plugin driver names its plugin');++ assert.strictEqual((await submit(mia, PROFILE, { license: 'WTFPL' })).status, 422);++ assert.strictEqual((await submit(mia, PROFILE, {}, { Origin: 'https://evil.test' })).status, 403);++ });++++ let subId;++ await check('a good submission waits for review; someone else cannot take its id meanwhile', async () => {++ const r = await submit(mia, PROFILE);++ assert.strictEqual(r.status, 303);++ assert.strictEqual(r.headers.get('location'), '/robots/submissions');++ const row = await t.db.maybe("SELECT * FROM profile_submissions WHERE profile_id = 'community.ballbot' AND status = 'pending'");++ assert.ok(row && row.submitter_subject === mia.subject && row.license === 'MIT' && row.driver_url === 'https://github.com/mia/ballbot-driver');++ subId = row.id;++ const mine = await (await get('/robots/submissions', mia)).text();++ assert.ok(mine.includes('community.ballbot') && mine.includes('Waiting for review') && !mine.includes('Approve and sign'), 'not a reviewer');++ assert.strictEqual((await submit(sam, PROFILE)).status, 409);++ assert.strictEqual((await get('/api/v1/profiles/community.ballbot')).status, 404, 'not in the catalogue yet');++ });++++ await check('only an admin reviews; a rejection needs a note', async () => {++ assert.strictEqual((await post(`/robots/submissions/${subId}/review`, mia, { decision: 'approve' })).status, 403);++ const queue = await (await get('/robots/submissions', ada)).text();++ assert.ok(queue.includes('Waiting for review') && queue.includes('Approve and sign') && queue.includes('From @mia'));++ assert.strictEqual((await post(`/robots/submissions/${subId}/review`, ada, { decision: 'reject' })).status, 422);++ });++++ await check('approval signs it and puts it in the catalogue: API with signature, /models, /robots/new, a robot', async () => {++ const r = await post(`/robots/submissions/${subId}/review`, ada, { decision: 'approve', note: 'Looks safe.' });++ assert.strictEqual(r.status, 303);++ const api = (await t.call('GET', '/api/v1/profiles/community.ballbot', { token: null })).json;++ assert.strictEqual(api.profile.name, 'Ballbot');++ const key = (await t.call('GET', '/api/v1/profiles/signing-key', { token: null })).json.key;++ assert.strictEqual(key.alg, 'Ed25519');++ assert.strictEqual(api.signature.key_id, key.key_id);++ const ok = crypto.verify(null, Buffer.from(canonicalJson(api.profile)), crypto.createPublicKey(key.public_key_pem), Buffer.from(api.signature.value, 'base64'));++ assert.ok(ok, 'the signature verifies with Bot\'s published key');++ const models = await (await get('/models')).text();++ assert.ok(models.includes('From the community') && models.includes('href="/models/community.ballbot"'));++ const page = await (await get('/models/community.ballbot')).text();++ assert.ok(page.includes('Submitted by @mia') && page.includes('https://github.com/mia/ballbot-driver') && page.includes(key.key_id));++ assert.match(await (await get('/robots/new?profile=community.ballbot', sam)).text(), /value="community\.ballbot" checked/);++ const made = await t.call('POST', '/api/v1/robots', { user: sam, body: { name: 'My ballbot', profile_id: 'community.ballbot' } });++ assert.strictEqual(made.status, 201, made.text);++ assert.strictEqual((await submit(sam, { ...PROFILE, version: 2 })).status, 409, 'the id is mia\'s now');++ assert.strictEqual((await submit(mia, PROFILE)).status, 409, 'version 1 is taken: a new one needs version 2');++ assert.strictEqual((await submit(mia, { ...PROFILE, version: 2, name: 'Ballbot 2' })).status, 303, 'its submitter sends a new version');++ });++++ await check('account deletion: the person\'s waiting submissions go; the approved profile stays, authorless', async () => {++ const accountData = require('../server/account-data').create({ domain: t.domain, log: { log() {}, warn() {} } });++ const out = await accountData.erase([mia.subject]);++ assert.strictEqual(out.erased.profile_submissions, 1, 'version 2, still waiting, is deleted');++ const left = await t.db.many("SELECT status, submitter_subject FROM profile_submissions WHERE profile_id = 'community.ballbot'");++ assert.deepStrictEqual(left.map((r) => [r.status, r.submitter_subject]), [['approved', null]]);++ assert.strictEqual((await t.call('GET', '/api/v1/profiles/community.ballbot', { token: null })).status, 200, 'robots keep their profile');++ });++++ await t.close();++ done();++})();
@@ -16,6 +16,7 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato
@@ -16,6 +16,7 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato
- 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.
- 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.
+- Community robots (`server/submissions.js`): anyone signed in submits a profile (id `community.<name>`, driver `plugin` for their own OpenVibe Node plugin, named in `mapping.plugin`, Contracts 0.160.0) with the driver's source, a setup guide and a licence at `/robots/submit`; `/robots/submissions` shows where each stands, and an OpenVibe admin approves or rejects (with a note) there. Approving signs the profile (Ed25519 over its canonical JSON, `BOT_PROFILE_SIGNING_KEY`) and puts it in the catalogue: `/models` "From the community", `/robots/new`, `GET /api/v1/profiles/:id` with its `signature`, and Bot's key at `GET /api/v1/profiles/signing-key`. Account deletion removes a person's unapproved submissions and keeps approved ones authorless.
- A phone as the robot (`phone.camera`, driver `phone`): the pairing page shows a QR code (`server/web/qr.js`) that opens `/device/phone` with the robot and code in the URL fragment; the phone pairs over `POST /api/v1/pair` and its browser is the device (`public/phone-device.js`): `/device` with the credential offered as the subprotocol `ov.device.<credential>` (a browser cannot set `Authorization`), its camera published over WHIP to OpenRestream, `say` through speech synthesis, `display` faces and text full-screen, a torch and a chime, battery and tilt telemetry, and a Stop button that latches a local stop. Nothing to install.
- A phone as the robot (`phone.camera`, driver `phone`): the pairing page shows a QR code (`server/web/qr.js`) that opens `/device/phone` with the robot and code in the URL fragment; the phone pairs over `POST /api/v1/pair` and its browser is the device (`public/phone-device.js`): `/device` with the credential offered as the subprotocol `ov.device.<credential>` (a browser cannot set `Authorization`), its camera published over WHIP to OpenRestream, `say` through speech synthesis, `display` faces and text full-screen, a torch and a chime, battery and tilt telemetry, and a Stop button that latches a local stop. Nothing to install.
- `/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.
@@ -129,7 +130,7 @@ Set `BOT_ONVIF_CAMERAS` (JSON, unset by default) to the server-side cameras Bot
@@ -129,7 +130,7 @@ Set `BOT_ONVIF_CAMERAS` (JSON, unset by default) to the server-side cameras Bot
OpenVibe.Sites no longer generates `deploy/nginx/openvibe.bot.conf` (sites.json marks the vhost as owned by OpenVibe.Bot), and Bot serves nothing from a Sites checkout: the legal pages, `robots.txt`, `sitemap.xml`, `manifest.webmanifest` and the 404 page all come from the app. A Sites deploy leaves an installed vhost it no longer generates in place, so the current Sites vhost keeps serving openvibe.bot until Bot's first deploy installs this file over it; later Sites deploys never overwrite Bot's `/device` and `/control` routes.
OpenVibe.Sites no longer generates `deploy/nginx/openvibe.bot.conf` (sites.json marks the vhost as owned by OpenVibe.Bot), and Bot serves nothing from a Sites checkout: the legal pages, `robots.txt`, `sitemap.xml`, `manifest.webmanifest` and the 404 page all come from the app. A Sites deploy leaves an installed vhost it no longer generates in place, so the current Sites vhost keeps serving openvibe.bot until Bot's first deploy installs this file over it; later Sites deploys never overwrite Bot's `/device` and `/control` routes.
<!-- versions:start -->
<!-- versions:start -->
-- openvibe-contracts: v0.157.0
+- openvibe-contracts: v0.160.0
- openvibe-sdk: v0.44.0
- openvibe-sdk: v0.44.0
- openvibe-shared: v4.1.3
- openvibe-shared: v4.1.3
<!-- versions:end -->
<!-- versions:end -->
@@ -5,11 +5,11 @@
@@ -5,11 +5,11 @@
"runtime": "node 22, express 4, ws; PostgreSQL 18 via openvibe-sdk/db (pg, or embedded PGlite in tests); Valkey optional; no build step",
"runtime": "node 22, express 4, ws; PostgreSQL 18 via openvibe-sdk/db (pg, or embedded PGlite in tests); Valkey optional; no build step",
"port": 4630,
"port": 4630,
"domain": "openvibe.bot (www -> apex; /device and /control WebSockets; /install)",
"domain": "openvibe.bot (www -> apex; /device and /control WebSockets; /install)",
- "contracts": "openvibe-contracts v0.157.0",
+ "contracts": "openvibe-contracts v0.160.0",
"sdk": "openvibe-sdk v0.44.0",
"sdk": "openvibe-sdk v0.44.0",
"shared": "openvibe-shared v4.1.3",
"shared": "openvibe-shared v4.1.3",
"packages": [
"packages": [
- "openvibe-contracts v0.157.0",
+ "openvibe-contracts v0.160.0",
"openvibe-sdk v0.44.0",
"openvibe-sdk v0.44.0",
"openvibe-shared v4.1.3"
"openvibe-shared v4.1.3"
],
],
@@ -45,6 +45,7 @@
@@ -45,6 +45,7 @@
"/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 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",
"a phone as the robot (profile phone.camera, driver phone, Contracts 0.157.0): the pairing page's QR code (server/web/qr.js, qrcode-generator) opens /device/phone with the robot and code in the fragment; the phone pairs over POST /api/v1/pair and its browser is the device (public/phone-device.js): /device with the credential as the subprotocol ov.device.<credential>, the camera over WHIP to OpenRestream, say (speech synthesis), display (faces, text), torch, chime, battery and tilt telemetry, a local Stop (estop_state local_stop); /device/phone has its own CSP (the WHIP ingest origin) and Permissions-Policy camera=(self)",
"a phone as the robot (profile phone.camera, driver phone, Contracts 0.157.0): the pairing page's QR code (server/web/qr.js, qrcode-generator) opens /device/phone with the robot and code in the fragment; the phone pairs over POST /api/v1/pair and its browser is the device (public/phone-device.js): /device with the credential as the subprotocol ov.device.<credential>, the camera over WHIP to OpenRestream, say (speech synthesis), display (faces, text), torch, chime, battery and tilt telemetry, a local Stop (estop_state local_stop); /device/phone has its own CSP (the WHIP ingest origin) and Permissions-Policy camera=(self)",
+ "community robots (server/submissions.js, migration 0010): submit at /robots/submit (id community.<name>, driver plugin + mapping.plugin, Contracts 0.160.0, https driver and guide links, licence), status at /robots/submissions, admin review there (approve signs Ed25519 over canonical JSON with BOT_PROFILE_SIGNING_KEY and inserts robot_profiles; reject needs a note); GET /api/v1/profiles/:id carries the signature, GET /api/v1/profiles/signing-key the public key; /models lists them under From the community with credits; in the ADR-033 export, and deletion drops unapproved ones and keeps approved ones authorless",
"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",
new file mode 100644
new file mode 100644
@@ -0,0 +1,32 @@
@@ -0,0 +1,32 @@
+-- phase: expand
+--
+-- Community robot profiles (plan T15 "people submit bots and guides and Bot connects them"): anyone signed in submits a
+-- profile (its id under community.), with the driver's source, a setup guide and a licence; an OpenVibe admin reviews
+-- it. An approved one is signed by Bot (Ed25519 over its canonical JSON, BOT_PROFILE_SIGNING_KEY) and joins the
+-- catalogue (robot_profiles), so robots can be added with it. Additive only.
+--
+-- Account deletion (ADR-033): a person's pending and rejected submissions are deleted; an approved one stays in the
+-- catalogue (robots may use it) with submitter_subject cleared.
+
+CREATE TABLE IF NOT EXISTS profile_submissions (
+ id text PRIMARY KEY, -- sub_…
+ submitter_subject text, -- usr_…; NULL once the account is deleted
+ profile_id text NOT NULL, -- community.…
+ profile_version integer NOT NULL,
+ profile jsonb NOT NULL, -- validated bot.robot-profile@1
+ driver_url text, -- the driver's source (https)
+ guide_url text, -- the setup guide (https)
+ license text,
+ notes text,
+ status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'rejected', 'withdrawn')),
+ review_note text,
+ reviewed_by text,
+ reviewed_at timestamptz,
+ signature text, -- base64 Ed25519 signature, on approval
+ key_id text, -- which signing key made it
+ created_at timestamptz NOT NULL,
+ updated_at timestamptz NOT NULL
+);
+CREATE INDEX IF NOT EXISTS profile_submissions_status_idx ON profile_submissions (status, created_at);
+CREATE INDEX IF NOT EXISTS profile_submissions_submitter_idx ON profile_submissions (submitter_subject);
+CREATE UNIQUE INDEX IF NOT EXISTS profile_submissions_approved_one ON profile_submissions (profile_id, profile_version) WHERE status = 'approved';
@@ -13,7 +13,7 @@
@@ -13,7 +13,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.157.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.160.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.3",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.3",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -845,9 +845,9 @@
@@ -845,9 +845,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.157.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.157.0",
- "integrity": "sha512-GhcDBEdSZOxuKjO3ivN7lZlm685ULRABNkOIxSpJIRwv9qTuATcJe/PwjHfUlcr+VmZeJkd2eJZJl07yIuomdw==",
+ "version": "0.160.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.160.0",
+ "integrity": "sha512-1hhbfO3js3IRrCH2XDTy6TVo9aPaSqSangyBRq1U2WdMvoToeujiucERZnXQCW8kIYWM6R+Ow5M7jNGi7ySnwQ==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -25,7 +25,7 @@
@@ -25,7 +25,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.157.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.160.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.3",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.3",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -79,3 +79,16 @@
@@ -79,3 +79,16 @@
.dir-row-actions { margin-left: 0; width: 100%; }
.dir-row-actions { margin-left: 0; width: 100%; }
.dir-live iframe { height: 86vh; }
.dir-live iframe { height: 86vh; }
}
}
+
+/* Community robots (/robots/submit, /robots/submissions, community models). */
+.dir-community { color: #2dd4bf; margin-left: 4px; }
+.dir-card-head > span { display: inline-flex; gap: 4px; flex-wrap: wrap; justify-content: flex-end; }
+.dir-field.dir-wide { max-width: none; }
+.dir-form textarea { font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; padding: 10px 12px; border-radius: 10px; border: 1px solid var(--border, #1f2d47); background: var(--bg-primary, #0a0f1c); color: var(--text-primary, #e6edf7); resize: vertical; width: 100%; box-sizing: border-box; }
+.dir-form textarea:focus, .dir-form input[type="url"]:focus { outline: 2px solid var(--accent, #3b82f6); outline-offset: 1px; }
+.dir-form input[type="url"] { 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-sub { display: block; }
+.dir-sub-body { padding: 0 18px 14px; }
+.dir-sub-body form { margin: 8px 0 0; }
+.dir-pre { max-height: 320px; overflow: auto; padding: 10px 12px; border-radius: 10px; background: var(--bg-primary, #0a0f1c); border: 1px solid var(--border, #1f2d47); font: 12.5px/1.45 ui-monospace, monospace; }
+.dir-review .dir-field input { font: inherit; min-height: 40px; padding: 0 10px; border-radius: 8px; border: 1px solid var(--border, #1f2d47); background: var(--bg-primary, #0a0f1c); color: var(--text-primary, #e6edf7); }
@@ -28,6 +28,10 @@ const TABLES = [
@@ -28,6 +28,10 @@ const TABLES = [
{ table: 'robot_operators', subject: 'added_by', file: null, erase: anonymize },
{ table: 'robot_operators', subject: 'added_by', file: null, erase: anonymize },
{ table: 'live_conversions', subject: 'owner_subject', file: null },
{ table: 'live_conversions', subject: 'owner_subject', file: null },
{ table: 'run_jobs', subject: 'subject', value: (usr) => [usr, `user:${usr}`], file: 'run-jobs.json', columns: ['id', 'class', 'state', 'wall_ms', 'exit_reason', 'created_at', 'finished_at'], erase: { keep: 'usage seconds reported to Billing' } },
{ table: 'run_jobs', subject: 'subject', value: (usr) => [usr, `user:${usr}`], file: 'run-jobs.json', columns: ['id', 'class', 'state', 'wall_ms', 'exit_reason', 'created_at', 'finished_at'], erase: { keep: 'usage seconds reported to Billing' } },
+ // Community robot profiles: the person's own submissions are exported; on deletion the ones still waiting, refused or
+ // withdrawn go (extraErase) and an approved one stays in the catalogue, authorless (robots may use it).
+ { table: 'profile_submissions', subject: 'submitter_subject', file: 'robot-submissions.json', columns: ['profile_id', 'profile_version', 'profile', 'driver_url', 'guide_url', 'license', 'notes', 'status', 'review_note', 'created_at'], order: 'created_at', erase: { keep: 'approved profiles stay in the catalogue, authorless (extraErase)' } },
+ { table: 'profile_submissions', subject: 'reviewed_by', file: null, erase: anonymize },
];
];
async function extraExport(db, subject) {
async function extraExport(db, subject) {
@@ -39,6 +43,9 @@ async function extraExport(db, subject) {
@@ -39,6 +43,9 @@ async function extraExport(db, subject) {
/** The account-data handle over Bot's domain (server/domain: robots.remove, devices.revoke). */
/** The account-data handle over Bot's domain (server/domain: robots.remove, devices.revoke). */
function create({ domain, log = console } = {}) {
function create({ domain, log = console } = {}) {
async function extraErase(t, subjects, counts) {
async function extraErase(t, subjects, counts) {
+ // A submission that never reached the catalogue is the person's alone; an approved one stays, without its author.
+ counts.add(counts.erased, 'profile_submissions', await t.exec("DELETE FROM profile_submissions WHERE submitter_subject = ANY($1::text[]) AND status <> 'approved'", [subjects]));
+ await t.exec("UPDATE profile_submissions SET submitter_subject = NULL WHERE submitter_subject = ANY($1::text[])", [subjects]);
const robots = await t.many('SELECT id FROM robots WHERE owner_subject = ANY($1::text[])', [subjects]);
const robots = await t.many('SELECT id FROM robots WHERE owner_subject = ANY($1::text[])', [subjects]);
const ids = robots.map((r) => r.id);
const ids = robots.map((r) => r.id);
if (ids.length) {
if (ids.length) {
@@ -49,7 +49,7 @@ const CAP = {
@@ -49,7 +49,7 @@ const CAP = {
dispatch: 'bot.job.dispatch',
dispatch: 'bot.job.dispatch',
};
};
-function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config }) {
+function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, submissions = null }) {
const r = express.Router();
const r = express.Router();
r.use(limits.reads('bot.read'));
r.use(limits.reads('bot.read'));
r.use('/resources', resourceIndex.router({ db: domain.db, apiAuth }));
r.use('/resources', resourceIndex.router({ db: domain.db, apiAuth }));
@@ -131,10 +131,18 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config }) {
@@ -131,10 +131,18 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config }) {
const rows = await listProfiles(domain.db);
const rows = await listProfiles(domain.db);
res.json({ profiles: rows.map((p) => p.profile) });
res.json({ profiles: rows.map((p) => p.profile) });
}));
}));
+ // Bot's public key for community profiles' signatures (server/submissions.js): Ed25519 over the canonical JSON.
+ r.get('/profiles/signing-key', wrap(async (req, res) => {
+ const key = submissions && submissions.signingKey();
+ if (!key) fail(404, 'bot.signing_unavailable', 'no profile signing key is configured');
+ res.json({ key });
+ }));
+ // A community profile also carries the signature its approval made, and where its driver and guide are.
r.get('/profiles/:id', wrap(async (req, res) => {
r.get('/profiles/:id', wrap(async (req, res) => {
const p = await getProfile(domain.db, req.params.id);
const p = await getProfile(domain.db, req.params.id);
if (!p) fail(404, 'bot.profile_not_found', 'no such profile');
if (!p) fail(404, 'bot.profile_not_found', 'no such profile');
- res.json({ profile: p.profile });
+ const signature = submissions && p.id.startsWith('community.') ? await submissions.signatureFor(p.id, p.version) : null;
+ res.json({ profile: p.profile, ...(signature ? { signature } : {}) });
}));
}));
// The same validator the /docs/profiles form runs (server/profiles checkProfile): a profile in,
// The same validator the /docs/profiles form runs (server/profiles checkProfile): a profile in,
// { valid, problems[] } out. Public, and rate-limited per address exactly as the form is.
// { valid, problems[] } out. Public, and rate-limited per address exactly as the form is.
@@ -41,6 +41,7 @@ const { createActorLimits } = require('./api/actor-limits');
@@ -41,6 +41,7 @@ const { createActorLimits } = require('./api/actor-limits');
const { createSessionRoutes, viewerMiddleware } = require('./web/session');
const { createSessionRoutes, viewerMiddleware } = require('./web/session');
const { createWebRoutes } = require('./web/routes');
const { createWebRoutes } = require('./web/routes');
const { HOME_CSP } = require('./web/home');
const { HOME_CSP } = require('./web/home');
+const { createSubmissions } = require('./submissions');
const { inputError } = require('./util');
const { inputError } = require('./util');
const { createBotReadiness, registerBotGauges } = require('./observability');
const { createBotReadiness, registerBotGauges } = require('./observability');
@@ -75,6 +76,7 @@ function createApp(opts = {}) {
@@ -75,6 +76,7 @@ function createApp(opts = {}) {
const identity = opts.identity || createIdentity(config, { fetchImpl });
const identity = opts.identity || createIdentity(config, { fetchImpl });
const openre = opts.openre !== undefined ? opts.openre : createOpenRe(config, { fetchImpl });
const openre = opts.openre !== undefined ? opts.openre : createOpenRe(config, { fetchImpl });
const domain = createDomain({ db, config, outbox, link: hub, nodes, openre, now, log });
const domain = createDomain({ db, config, outbox, link: hub, nodes, openre, now, log });
+ const submissions = createSubmissions({ db, config, now, log });
hub.bindDomain(domain);
hub.bindDomain(domain);
// One jobs service for the device socket and the jobs API, so both see the same stdout rings.
// One jobs service for the device socket and the jobs API, so both see the same stdout rings.
const jobs = createJobs({ db: () => domain.db, hub, usage, log, now });
const jobs = createJobs({ db: () => domain.db, hub, usage, log, now });
@@ -127,7 +129,7 @@ function createApp(opts = {}) {
@@ -127,7 +129,7 @@ function createApp(opts = {}) {
release.mount(app, { registry: metrics.registry });
release.mount(app, { registry: metrics.registry });
const limits = createActorLimits({ config, valkey, now: opts.limitsNow || now, registry: metrics.registry, log });
const limits = createActorLimits({ config, valkey, now: opts.limitsNow || now, registry: metrics.registry, log });
- app.use('/api/v1', express.json({ limit: '64kb' }), (req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); }, apiAuth.middleware, v1Router({ domain, apiAuth, limits, hub, jobs, config }));
+ app.use('/api/v1', express.json({ limit: '64kb' }), (req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); }, apiAuth.middleware, v1Router({ domain, apiAuth, limits, hub, jobs, config, submissions }));
app.use('/auth', createSessionRoutes(config, userAuth, { fetchImpl }));
app.use('/auth', createSessionRoutes(config, userAuth, { fetchImpl }));
// ── The legal pages: Terms, Privacy and DMCA on openvibe.bot itself (openvibe-shared/legal). ──
// ── The legal pages: Terms, Privacy and DMCA on openvibe.bot itself (openvibe-shared/legal). ──
// The document carries its own inline theme and OpenVibe Frame boot, which the strict site-wide CSP
// The document carries its own inline theme and OpenVibe Frame boot, which the strict site-wide CSP
@@ -136,7 +138,7 @@ function createApp(opts = {}) {
@@ -136,7 +138,7 @@ function createApp(opts = {}) {
{ const legal = require('openvibe-shared/legal');
{ const legal = require('openvibe-shared/legal');
app.get(legal.PATHS, (req, res, next) => { res.setHeader('Content-Security-Policy', HOME_CSP); next(); }, legal.handler({ id: 'bot', service: 'bot', host: 'openvibe.bot', name: 'OpenVibe.Bot', profile: 'games' })); }
app.get(legal.PATHS, (req, res, next) => { res.setHeader('Content-Security-Policy', HOME_CSP); next(); }, legal.handler({ id: 'bot', service: 'bot', host: 'openvibe.bot', name: 'OpenVibe.Bot', profile: 'games' })); }
app.use(viewerMiddleware(userAuth));
app.use(viewerMiddleware(userAuth));
- app.use(createWebRoutes(config, { domain, sim, onvif, limits, identity, hub, log, releaseLastmod: release.full().released_at === release.full().booted_at ? undefined : release.full().released_at }));
+ app.use(createWebRoutes(config, { domain, sim, onvif, limits, identity, hub, log, submissions, releaseLastmod: release.full().released_at === release.full().booted_at ? undefined : release.full().released_at }));
app.use((req, res) => {
app.use((req, res) => {
if (req.path.startsWith('/api/') || req.path.startsWith('/internal/')) return http.sendProblem(res, 404, 'not_found', { ctx: req.ov });
if (req.path.startsWith('/api/') || req.path.startsWith('/internal/')) return http.sendProblem(res, 404, 'not_found', { ctx: req.ov });
@@ -155,7 +157,7 @@ function createApp(opts = {}) {
@@ -155,7 +157,7 @@ function createApp(opts = {}) {
return res.status(500).type('text/plain').send('Something went wrong\n');
return res.status(500).type('text/plain').send('Something went wrong\n');
});
});
- Object.assign(app.locals, { config, db, valkey, domain, keys, outbox, usage, hub, jobs, sim, onvif, userAuth, metrics });
+ Object.assign(app.locals, { config, db, valkey, domain, keys, outbox, usage, hub, jobs, sim, onvif, userAuth, metrics, submissions });
return app;
return app;
}
}
@@ -208,6 +208,11 @@ function loadConfig(env = process.env) {
@@ -208,6 +208,11 @@ function loadConfig(env = process.env) {
media: {
media: {
whipBase: trim(env.BOT_WHIP_BASE || ''),
whipBase: trim(env.BOT_WHIP_BASE || ''),
},
},
+ // Community robot profiles (server/submissions.js): the Ed25519 key an approval signs a profile with. Unset means
+ // submissions are taken and reviewed but none can be approved (the review says why).
+ profiles: {
+ signingKey: env.BOT_PROFILE_SIGNING_KEY || '',
+ },
// Server-side ONVIF cameras (driver `onvif`, server/onvif): the hosts Bot may connect to, keyed by
// Server-side ONVIF cameras (driver `onvif`, server/onvif): the hosts Bot may connect to, keyed by
// robot id (one camera per robot), plus the timeout each request gets. Credentials are secret
// robot id (one camera per robot), plus the timeout each request gets. Credentials are secret
// references only (username_ref/password_ref), resolved when the request is built; the config never
// references only (username_ref/password_ref), resolved when the request is built; the config never
@@ -50,7 +50,10 @@ const WIDGETS = new Set(['drive', 'pan-tilt', 'servo', 'lights', 'horn', 'speake
@@ -50,7 +50,10 @@ const WIDGETS = new Set(['drive', 'pan-tilt', 'servo', 'lights', 'horn', 'speake
/** Device drivers a mapping may name. `relay` is a Node plugin with no hardware map of its own. */
/** Device drivers a mapping may name. `relay` is a Node plugin with no hardware map of its own. */
// `looi`: the community's Generic LOOI driver, a Bluetooth bridge plugin (OpenVibe.Node docs/hardware-looi.md).
// `looi`: the community's Generic LOOI driver, a Bluetooth bridge plugin (OpenVibe.Node docs/hardware-looi.md).
// `phone`: a phone that is the device itself, from its browser (public/phone-device.js), no Node and nothing installed.
// `phone`: a phone that is the device itself, from its browser (public/phone-device.js), no Node and nothing installed.
-const DRIVERS = new Set(['pca9685', 'ads7830', 'cozmo', 'looi', 'onvif', 'sim', 'relay', 'phone']);
+// `plugin`: a community OpenVibe Node plugin, named in mapping.plugin, that the robot's owner installs (Contracts 0.160.0;
+// community profiles are reviewed and signed before robots can use them, server/submissions.js).
+const DRIVERS = new Set(['pca9685', 'ads7830', 'cozmo', 'looi', 'onvif', 'sim', 'relay', 'phone', 'plugin']);
+const PLUGIN_NAME = /^[a-z][a-z0-9_-]{1,40}$/;
const ID_RE = /^[a-z][a-z0-9._-]{1,63}$/;
const ID_RE = /^[a-z][a-z0-9._-]{1,63}$/;
const KINDS = new Set(['onboard', 'bridge', 'server']);
const KINDS = new Set(['onboard', 'bridge', 'server']);
@@ -190,6 +193,7 @@ function validateProfile(p) {
@@ -190,6 +193,7 @@ function validateProfile(p) {
return w.command != null ? { ...w, command: validateWidgetCommand(p.id, w, commands) } : { ...w };
return w.command != null ? { ...w, command: validateWidgetCommand(p.id, w, commands) } : { ...w };
});
});
if (!p.mapping || typeof p.mapping !== 'object' || !DRIVERS.has(p.mapping.driver)) bad(`${p.id}: mapping.driver must be one of ${[...DRIVERS].join(', ')}`);
if (!p.mapping || typeof p.mapping !== 'object' || !DRIVERS.has(p.mapping.driver)) bad(`${p.id}: mapping.driver must be one of ${[...DRIVERS].join(', ')}`);
+ if (p.mapping.driver === 'plugin' && !PLUGIN_NAME.test(String(p.mapping.plugin || ''))) bad(`${p.id}: a plugin driver names its Node plugin in mapping.plugin (lower case letters, digits, - and _)`);
const l = p.limits && typeof p.limits === 'object' ? p.limits : {};
const l = p.limits && typeof p.limits === 'object' ? p.limits : {};
const limits = {
const limits = {
max_speed: number(l.max_speed) != null ? number(l.max_speed) : 1,
max_speed: number(l.max_speed) != null ? number(l.max_speed) : 1,
new file mode 100644
new file mode 100644
@@ -0,0 +1,157 @@
@@ -0,0 +1,157 @@
+'use strict';
+
+/**
+ * Community robot profiles (plan T15 "people submit bots and guides and Bot connects them": submissions are reviewed
+ * and signed before they run on anyone's Node). Anyone signed in submits a profile whose id is under `community.`,
+ * with the driver's source, a setup guide and a licence; an OpenVibe admin approves or rejects it. Approving signs the
+ * profile (Ed25519 over its canonical JSON, BOT_PROFILE_SIGNING_KEY) and puts it in the catalogue (robot_profiles), so
+ * robots can be added with it; the signature and Bot's public key are published so a Node or anyone can check it.
+ *
+ * createSubmissions({ db, config, now })
+ * submit(person, { profile, driver_url, guide_url, license, notes }) → the submission row (pending)
+ * withdraw(id, person) / review(id, reviewer, { decision: 'approve'|'reject', note })
+ * get(id), mine(subject), pending(), community() (community(): approved, newest first)
+ * signatureFor(profileId, version) → { alg, key_id, value } | null
+ * signingKey() → { alg, key_id, public_key_pem } | null (null when no key is configured)
+ * canonicalJson(value) (sorted keys, no spaces: what is signed)
+ */
+const crypto = require('crypto');
+const { checkProfile } = require('./profiles');
+const { BotError, prefixedId, iso, text } = require('./util');
+
+const COMMUNITY_ID = /^community\.[a-z][a-z0-9_-]{1,40}$/;
+const LICENSES = ['MIT', 'Apache-2.0', 'BSD-3-Clause', 'GPL-3.0-only', 'LGPL-3.0-only', 'MPL-2.0', 'CC0-1.0', 'other'];
+const ALG = 'Ed25519';
+
+function fail(status, code, detail) { throw new BotError(status, code, detail); }
+
+/** JSON with every object's keys sorted and no whitespace: the exact bytes Bot signs. */
+function canonicalJson(v) {
+ if (Array.isArray(v)) return `[${v.map(canonicalJson).join(',')}]`;
+ if (v && typeof v === 'object') return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${canonicalJson(v[k])}`).join(',')}}`;
+ return JSON.stringify(v);
+}
+
+/** BOT_PROFILE_SIGNING_KEY: an Ed25519 private key as PEM (literal \n allowed) or base64 of the PEM or of PKCS#8 DER. */
+function loadKey(raw) {
+ const s = String(raw || '').trim();
+ if (!s) return null;
+ let key;
+ try {
+ if (s.includes('BEGIN')) key = crypto.createPrivateKey(s.replace(/\\n/g, '\n'));
+ else {
+ const buf = Buffer.from(s, 'base64');
+ const pem = buf.toString('utf8');
+ key = pem.includes('BEGIN') ? crypto.createPrivateKey(pem) : crypto.createPrivateKey({ key: buf, format: 'der', type: 'pkcs8' });
+ }
+ } catch (e) { throw new Error(`BOT_PROFILE_SIGNING_KEY is not a private key: ${e.message}`); }
+ if (key.asymmetricKeyType !== 'ed25519') throw new Error('BOT_PROFILE_SIGNING_KEY must be an Ed25519 key');
+ const pub = crypto.createPublicKey(key);
+ const der = pub.export({ type: 'spki', format: 'der' });
+ return { key, keyId: crypto.createHash('sha256').update(der).digest('hex').slice(0, 16), publicPem: pub.export({ type: 'spki', format: 'pem' }) };
+}
+
+function httpsUrl(v, name) {
+ const s = typeof v === 'string' ? v.trim() : '';
+ if (!s) return null;
+ let u;
+ try { u = new URL(s); } catch { fail(422, 'bot.invalid_input', `${name} is not a URL`); }
+ if (u.protocol !== 'https:' || u.username || u.password || s.length > 300) fail(422, 'bot.invalid_input', `${name} must be an https link of at most 300 characters`);
+ return u.href;
+}
+
+function createSubmissions({ db, config = {}, now = () => Date.now(), log = console }) {
+ // A key that does not load is logged and leaves approvals refused; it never stops the service.
+ let signer = null;
+ try { signer = loadKey(config.profiles && config.profiles.signingKey); } catch (e) { log.warn(`[Bot] ${e.message}; community profiles cannot be approved`); }
+
+ async function get(id) { return db.maybe('SELECT * FROM profile_submissions WHERE id = $1', [id]); }
+ async function mine(subject) { return db.many('SELECT * FROM profile_submissions WHERE submitter_subject = $1 ORDER BY created_at DESC LIMIT 100', [subject]); }
+ async function pending() { return db.many("SELECT * FROM profile_submissions WHERE status = 'pending' ORDER BY created_at LIMIT 200"); }
+ async function community() {
+ return db.many(`SELECT DISTINCT ON (profile_id) * FROM profile_submissions WHERE status = 'approved'
+ ORDER BY profile_id, profile_version DESC`);
+ }
+
+ async function submit(person, { profile, driver_url = null, guide_url = null, license = null, notes = null } = {}) {
+ if (!person || !person.subject) fail(401, 'bot.sign_in', 'sign in to submit a robot');
+ if (!profile || typeof profile !== 'object' || Array.isArray(profile)) fail(422, 'bot.invalid_input', 'the profile must be a JSON object');
+ if (!COMMUNITY_ID.test(String(profile.id || ''))) fail(422, 'bot.invalid_input', 'a community profile\'s id is community.<name> (lower case letters, digits, - and _)');
+ const checked = checkProfile(profile);
+ if (!checked.valid) fail(422, 'bot.profile_invalid', checked.problems.join('; '));
+ const p = checked.profile;
+ const driver = httpsUrl(driver_url, 'the driver link');
+ const guide = httpsUrl(guide_url, 'the setup guide link');
+ if (!driver && p.mapping.driver === 'plugin') fail(422, 'bot.invalid_input', 'a robot driven by a community plugin needs a link to the plugin\'s source');
+ const lic = license == null || license === '' ? null : String(license);
+ if (lic && !LICENSES.includes(lic)) fail(422, 'bot.invalid_input', `licence must be one of ${LICENSES.join(', ')}`);
+ const note = text(notes, 'notes', 2000) || null;
+
+ // The id is someone's once it is in the catalogue: only its original submitter sends a newer version of it.
+ const owners = await db.many("SELECT DISTINCT submitter_subject FROM profile_submissions WHERE profile_id = $1 AND status = 'approved'", [p.id]);
+ if (owners.some((o) => o.submitter_subject && o.submitter_subject !== person.subject)) fail(409, 'bot.profile_taken', 'that profile id belongs to someone else; choose another');
+ const latest = await db.maybe('SELECT max(version) AS v FROM robot_profiles WHERE id = $1', [p.id]);
+ if (latest && latest.v != null && p.version <= latest.v) fail(409, 'bot.profile_version', `version ${latest.v} is already in the catalogue; submit version ${latest.v + 1} or later`);
+ const rival = await db.maybe("SELECT 1 FROM profile_submissions WHERE profile_id = $1 AND status = 'pending' AND submitter_subject <> $2", [p.id, person.subject]);
+ if (rival) fail(409, 'bot.profile_taken', 'someone else has a submission with that id waiting for review; choose another');
+
+ const id = prefixedId('sub', now());
+ const at = iso(now());
+ await db.tx(async (t) => {
+ // A newer submission of the same profile by the same person replaces their pending one.
+ await t.query("UPDATE profile_submissions SET status = 'withdrawn', updated_at = $3 WHERE profile_id = $1 AND submitter_subject = $2 AND status = 'pending'", [p.id, person.subject, at]);
+ await t.query(`INSERT INTO profile_submissions (id, submitter_subject, profile_id, profile_version, profile, driver_url, guide_url, license, notes, status, created_at, updated_at)
+ VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7, $8, $9, 'pending', $10, $10)`,
+ [id, person.subject, p.id, p.version, JSON.stringify(p), driver, guide, lic, note, at]);
+ });
+ return get(id);
+ }
+
+ async function withdraw(id, person) {
+ const row = await get(id);
+ if (!row) fail(404, 'bot.submission_not_found', 'no such submission');
+ if (!person || row.submitter_subject !== person.subject) fail(403, 'bot.forbidden', 'only its submitter may withdraw it');
+ if (row.status !== 'pending') fail(409, 'bot.submission_closed', `it is already ${row.status}`);
+ await db.query("UPDATE profile_submissions SET status = 'withdrawn', updated_at = $2 WHERE id = $1", [id, iso(now())]);
+ return get(id);
+ }
+
+ async function review(id, reviewer, { decision, note = null } = {}) {
+ if (!reviewer || reviewer.role !== 'admin') fail(403, 'bot.forbidden', 'only an OpenVibe admin reviews community robots');
+ const row = await get(id);
+ if (!row) fail(404, 'bot.submission_not_found', 'no such submission');
+ if (row.status !== 'pending') fail(409, 'bot.submission_closed', `it is already ${row.status}`);
+ const why = text(note, 'note', 1000) || null;
+ const at = iso(now());
+ if (decision === 'reject') {
+ if (!why) fail(422, 'bot.invalid_input', 'say why, so its submitter can fix it');
+ await db.query("UPDATE profile_submissions SET status = 'rejected', review_note = $2, reviewed_by = $3, reviewed_at = $4, updated_at = $4 WHERE id = $1", [id, why, reviewer.subject, at]);
+ return get(id);
+ }
+ if (decision !== 'approve') fail(422, 'bot.invalid_input', 'decision is approve or reject');
+ if (!signer) fail(503, 'bot.signing_unavailable', 'no profile signing key is configured (BOT_PROFILE_SIGNING_KEY)');
+ const profile = typeof row.profile === 'string' ? JSON.parse(row.profile) : row.profile;
+ const checked = checkProfile(profile); // the rules may have moved since it was submitted
+ if (!checked.valid) fail(422, 'bot.profile_invalid', checked.problems.join('; '));
+ const signature = crypto.sign(null, Buffer.from(canonicalJson(checked.profile)), signer.key).toString('base64');
+ await db.tx(async (t) => {
+ const taken = await t.maybe('SELECT 1 FROM robot_profiles WHERE id = $1 AND version = $2', [row.profile_id, row.profile_version]);
+ if (taken) fail(409, 'bot.profile_version', 'that version is already in the catalogue');
+ await t.query('INSERT INTO robot_profiles (id, version, profile, created_at) VALUES ($1, $2, $3::jsonb, $4)', [row.profile_id, row.profile_version, JSON.stringify(checked.profile), at]);
+ await t.query(`UPDATE profile_submissions SET status = 'approved', review_note = $2, reviewed_by = $3, reviewed_at = $4, updated_at = $4, signature = $5, key_id = $6
+ WHERE id = $1`, [id, why, reviewer.subject, at, signature, signer.keyId]);
+ });
+ return get(id);
+ }
+
+ async function signatureFor(profileId, version) {
+ const row = await db.maybe("SELECT signature, key_id FROM profile_submissions WHERE profile_id = $1 AND profile_version = $2 AND status = 'approved'", [profileId, version]);
+ return row && row.signature ? { alg: ALG, key_id: row.key_id, value: row.signature } : null;
+ }
+
+ function signingKey() { return signer ? { alg: ALG, key_id: signer.keyId, public_key_pem: signer.publicPem } : null; }
+
+ return { submit, withdraw, review, get, mine, pending, community, signatureFor, signingKey };
+}
+
+module.exports = { createSubmissions, canonicalJson, loadKey, COMMUNITY_ID, LICENSES };
new file mode 100644
new file mode 100644
@@ -0,0 +1,53 @@
@@ -0,0 +1,53 @@
+{
+ "id": "community.my-robot",
+ "version": 1,
+ "name": "My robot",
+ "vendor": "Me",
+ "kind": "onboard",
+ "description": "What it is, what it drives, and what it needs.",
+ "capabilities": [
+ "drive.differential",
+ "battery"
+ ],
+ "mapping": {
+ "driver": "plugin",
+ "plugin": "my_robot"
+ },
+ "commands": {
+ "drive": {
+ "axes": {
+ "throttle": [
+ -1,
+ 1
+ ],
+ "steer": [
+ -1,
+ 1
+ ]
+ }
+ }
+ },
+ "widgets": [
+ {
+ "type": "drive",
+ "capability": "drive.differential",
+ "label": "Drive",
+ "order": 1,
+ "command": {
+ "kind": "drive"
+ }
+ },
+ {
+ "type": "battery",
+ "capability": "battery",
+ "label": "Battery",
+ "order": 2
+ }
+ ],
+ "limits": {
+ "max_speed": 0.6,
+ "max_turn": 0.6,
+ "max_command_ms": 300,
+ "heartbeat_ms": 1000
+ }
+}
@@ -82,19 +82,26 @@ const CAPABILITY_WORDS = {
@@ -82,19 +82,26 @@ const CAPABILITY_WORDS = {
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.' };
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. */
/** A catalogue profile as a model: its own fields plus MODELS. */
-function modelOf(profile) {
+/**
+ * A catalogue profile as a model. `community` is the approved submission that put a community profile in the catalogue
+ * ({ by, driver_url, guide_url, license, key_id }); its guide becomes the model's guide.
+ */
+function modelOf(profile, community = null) {
const extra = MODELS[profile.id] || {};
const extra = MODELS[profile.id] || {};
const connect = extra.connect || (profile.kind === 'bridge' ? 'bridge' : profile.kind === 'server' ? 'server' : 'onboard');
const connect = extra.connect || (profile.kind === 'bridge' ? 'bridge' : profile.kind === 'server' ? 'server' : 'onboard');
+ const guide = extra.guide || (community && community.guide_url ? { href: community.guide_url, label: 'Setup guide' } : null);
return {
return {
id: profile.id, name: profile.name, vendor: profile.vendor || '', description: profile.description || '',
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,
+ connect, order: extra.order != null ? extra.order : community ? 200 : 100, setup: extra.setup || [], guide,
capabilities: (profile.capabilities || []).filter((c) => CAPABILITY_WORDS[c]), widgets: profile.widgets || [], profile,
capabilities: (profile.capabilities || []).filter((c) => CAPABILITY_WORDS[c]), widgets: profile.widgets || [], profile,
+ community,
};
};
}
}
const sortModels = (models) => models.slice().sort((a, b) => a.order - b.order || a.name.localeCompare(b.name));
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 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 badge = (connect) => `<span class="dir-badge dir-${esc(connect)}">${esc(CONNECT[connect].label)}</span>`;
+const communityBadge = (m) => (m.community ? ' <span class="dir-badge dir-community">Community</span>' : '');
const link = (href, text, cls = '') => `<a${cls ? ` class="${cls}"` : ''} href="${esc(href)}"${/^https?:/.test(href) ? ' rel="noopener"' : ''}>${esc(text)}</a>`;
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. */
/** A card's line: the description's first clause, cut at a word near `max` characters. */
function short(text, max = 120) {
function short(text, max = 120) {
@@ -109,7 +116,7 @@ function shell({ config, path, title, description, body }) {
@@ -109,7 +116,7 @@ function shell({ config, path, title, description, body }) {
}
}
function modelCard(m) {
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>`
+ 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><span>${badge(m.connect)}${communityBadge(m)}</span></span>`
+ `<span class="dir-card-text">${esc(short(m.description))}</span></a>${chips(m.capabilities.slice(0, 6))}</li>`;
+ `<span class="dir-card-text">${esc(short(m.description))}</span></a>${chips(m.capabilities.slice(0, 6))}</li>`;
}
}
@@ -139,20 +146,24 @@ ${listed.length ? `<ul class="dir-grid">${listed.map(robotCard).join('')}</ul>`
@@ -139,20 +146,24 @@ ${listed.length ? `<ul class="dir-grid">${listed.map(robotCard).join('')}</ul>`
<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>
<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>
<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>
<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>`;
+<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, then submit it so anyone can add one.</p>
+<p class="dir-actions">${link('/robots/submit', 'Submit a robot', 'dir-btn dir-primary')} ${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 });
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 ─────────────────────────────────────────────────────────────────────────────────────
// ── GET /models ─────────────────────────────────────────────────────────────────────────────────────
function renderModels({ config, models = [] }) {
function renderModels({ config, models = [] }) {
- const groups = ['browser', 'phone', 'onboard', 'bridge', 'server'].map((k) => [k, sortModels(models).filter((m) => m.connect === k)]).filter(([, ms]) => ms.length);
+ const official = models.filter((m) => !m.community);
+ const community = sortModels(models.filter((m) => m.community));
+ const groups = ['browser', 'phone', 'onboard', 'bridge', 'server'].map((k) => [k, sortModels(official).filter((m) => m.connect === k)]).filter(([, ms]) => ms.length);
const body = `<header class="dir-hero"><h1>Models</h1>
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>
<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>
${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')}
<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>`;
+${community.length ? `<section class="dir-sec" aria-labelledby="g-community"><h2 id="g-community">From the community</h2><p class="muted">Profiles people submitted and an OpenVibe admin reviewed. Each one is signed by OpenVibe.Bot; its driver is the submitter's.</p>
+<ul class="dir-grid">${community.map(modelCard).join('')}</ul></section>` : ''}
+<section class="dir-sec"><h2>Not here?</h2><p class="muted">Write a profile for it and check it with the validator, then submit it: once it is reviewed, anyone can add a robot with it. A driver for its hardware runs under the OpenVibe Node.</p>
+<p class="dir-actions">${link('/robots/submit', 'Submit a robot', 'dir-btn dir-primary')} ${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 });
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 });
}
}
@@ -165,7 +176,7 @@ ${kit.build_guide_url ? `<p>${link(kit.build_guide_url, 'The kit\'s build guide'
@@ -165,7 +176,7 @@ ${kit.build_guide_url ? `<p>${link(kit.build_guide_url, 'The kit\'s build guide'
? `${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('/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')}` : ''}`;
: `${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>
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>
+<header class="dir-hero"><h1>${esc(model.name)}</h1><p>${badge(model.connect)}${communityBadge(model)}${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>
<p class="muted">${esc(model.description)}</p><p class="dir-actions">${add}</p></header>
<div class="dir-cols">
<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>
<section class="dir-sec card"><h2>How it connects</h2><p><b>${esc(CONNECT[model.connect].label)}.</b> ${esc(CONNECT[model.connect].text)}</p>
@@ -173,6 +184,10 @@ ${model.setup.length ? `<ol>${model.setup.map((s) => `<li>${esc(s)}</li>`).join(
@@ -173,6 +184,10 @@ ${model.setup.length ? `<ol>${model.setup.map((s) => `<li>${esc(s)}</li>`).join(
<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>
<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>
</div>
${parts}
${parts}
+${model.community ? `<section class="dir-sec card"><h2>From the community</h2><p>Submitted by ${esc(ownerName(model.community.by))} and reviewed by an OpenVibe admin.${model.community.license ? ` Licence: ${esc(model.community.license)}.` : ''}</p>
+<ul>${model.community.driver_url ? `<li>Driver: ${link(model.community.driver_url, model.community.driver_url)}</li>` : ''}${model.community.guide_url ? `<li>Setup guide: ${link(model.community.guide_url, model.community.guide_url)}</li>` : ''}
+<li>Signed by OpenVibe.Bot (Ed25519, key <code>${esc(model.community.key_id || '')}</code>): ${link(`/api/v1/profiles/${model.id}`, 'the profile and its signature')} · ${link('/api/v1/profiles/signing-key', 'the public key')}</li></ul>
+<p class="muted">Install the driver from its source on the robot's computer; OpenVibe.Bot never runs it.</p></section>` : ''}
<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>`;
<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 });
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 });
}
}
@@ -215,4 +230,54 @@ ${groups.map(([title, ms]) => `<p class="dir-group">${esc(title)}</p><div class=
@@ -215,4 +230,54 @@ ${groups.map(([title, ms]) => `<p class="dir-group">${esc(title)}</p><div class=
return shell({ config, path: '/robots/new', title: 'Add a robot', description: 'Add a robot to OpenVibe.Bot.', body });
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 };
+// ── GET /robots/submit, /robots/submissions ────────────────────────────────────────────────────────
+// The profile the submit form starts with (a JSON file: its robot capability names are not Network capabilities).
+const TEMPLATE = require('./community-template.json');
+const STATUS_WORDS = { pending: 'Waiting for review', approved: 'Approved', rejected: 'Not approved', withdrawn: 'Withdrawn' };
+
+function renderSubmit({ config, licenses = [], values = {}, error = null }) {
+ const profile = values.profile != null ? values.profile : JSON.stringify(TEMPLATE, null, 2);
+ const body = `<p class="dir-crumb">${link('/robots', 'Robots')} / ${link('/models', 'Models')}</p>
+<header class="dir-hero"><h1>Submit a robot</h1><p class="muted">Describe your robot with a profile and point at its driver. An OpenVibe admin reviews it; once it is approved, OpenVibe.Bot signs it, it appears under <b>From the community</b>, and anyone can add a robot with it. ${link('/docs/profiles', 'How profiles work')} · ${link('/docs/drivers', 'Write a driver')}</p></header>
+<form class="dir-form" method="post" action="/robots/submit">
+${error ? `<p class="dir-error" role="alert">${esc(error)}</p>` : ''}
+<label class="dir-field dir-wide"><span class="dir-field-label">Profile (bot.robot-profile@1)</span><textarea name="profile" rows="18" spellcheck="false" required>${esc(profile)}</textarea>
+<span class="muted dir-note">Its id is <code>community.<name></code>. A robot run by your own Node plugin uses <code>"driver": "plugin"</code> and names it in <code>"plugin"</code>.</span></label>
+<label class="dir-field"><span class="dir-field-label">Driver source (https)</span><input type="url" name="driver_url" maxlength="300" placeholder="https://github.com/you/your-driver" value="${esc(values.driver_url || '')}"></label>
+<label class="dir-field"><span class="dir-field-label">Setup guide (https)</span><input type="url" name="guide_url" maxlength="300" placeholder="https://openvibe.wiki/…" value="${esc(values.guide_url || '')}"></label>
+<label class="dir-field"><span class="dir-field-label">Driver licence</span><select name="license"><option value="">Choose…</option>${licenses.map((l) => `<option value="${esc(l)}"${l === values.license ? ' selected' : ''}>${esc(l)}</option>`).join('')}</select></label>
+<label class="dir-field dir-wide"><span class="dir-field-label">Notes for the reviewer</span><textarea name="notes" rows="4" maxlength="2000">${esc(values.notes || '')}</textarea></label>
+<p><button type="submit" class="dir-btn dir-primary">Submit for review</button></p>
+</form>`;
+ return shell({ config, path: '/robots/submit', title: 'Submit a robot', description: 'Submit a community robot profile to OpenVibe.Bot.', body });
+}
+
+function submissionRow(sub, { review = false, by = null } = {}) {
+ const p = typeof sub.profile === 'string' ? JSON.parse(sub.profile) : sub.profile;
+ const head = `<span class="dir-card-head"><b>${esc(p.name)} <code>${esc(sub.profile_id)}</code> v${esc(sub.profile_version)}</b><span class="dir-pill">${esc(STATUS_WORDS[sub.status] || sub.status)}</span></span>`;
+ const links = [sub.driver_url && link(sub.driver_url, 'driver'), sub.guide_url && link(sub.guide_url, 'guide'), sub.license && esc(sub.license)].filter(Boolean).join(' · ');
+ const note = sub.review_note ? `<p class="muted">Reviewer: ${esc(sub.review_note)}</p>` : '';
+ const done = sub.status === 'approved' ? `<p>${link(`/models/${sub.profile_id}`, 'Its model page')}</p>` : '';
+ const withdraw = !review && sub.status === 'pending' ? `<form method="post" action="/robots/submissions/${esc(sub.id)}/withdraw"><button type="submit" class="dir-btn">Withdraw</button></form>` : '';
+ const decide = review ? `<details><summary>The profile</summary><pre class="dir-pre">${esc(JSON.stringify(p, null, 2))}</pre></details>
+${sub.notes ? `<p class="muted">Notes: ${esc(sub.notes)}</p>` : ''}
+<form class="dir-review" method="post" action="/robots/submissions/${esc(sub.id)}/review">
+<label class="dir-field dir-wide"><span class="dir-field-label">Note (needed to reject)</span><input type="text" name="note" maxlength="1000"></label>
+<p class="dir-actions"><button type="submit" name="decision" value="approve" class="dir-btn dir-primary">Approve and sign</button> <button type="submit" name="decision" value="reject" class="dir-btn">Reject</button></p>
+</form>` : '';
+ return `<li class="dir-card dir-sub"><div class="dir-card-link">${head}<span class="dir-card-text">${review ? `From ${esc(ownerName(by))} · ` : ''}${links || 'no links'}</span></div><div class="dir-sub-body">${note}${done}${withdraw}${decide}</div></li>`;
+}
+
+function renderSubmissions({ config, mine = [], pending = null, names = new Map(), signing = null }) {
+ const body = `<p class="dir-crumb">${link('/robots', 'Robots')} / ${link('/models', 'Models')}</p>
+<header class="dir-hero"><h1>Community robots</h1><p class="muted">Your submitted profiles and where each one stands. ${link('/robots/submit', 'Submit another')}</p></header>
+<section class="dir-sec" aria-labelledby="mine-h"><h2 id="mine-h">Yours</h2>
+${mine.length ? `<ul class="dir-list">${mine.map((s) => submissionRow(s)).join('')}</ul>` : '<p class="muted">Nothing submitted yet.</p>'}</section>
+${pending ? `<section class="dir-sec" aria-labelledby="queue-h"><h2 id="queue-h">Waiting for review</h2>
+${signing ? '' : '<p class="dir-error" role="alert">No profile signing key is configured, so nothing can be approved yet (BOT_PROFILE_SIGNING_KEY).</p>'}
+<p class="muted">Check the profile's commands and limits are safe for the hardware it names, that its driver's source is public and matches, and that the guide is a real guide. Approving signs it and puts it in the catalogue.</p>
+${pending.length ? `<ul class="dir-list">${pending.map((s) => submissionRow(s, { review: true, by: names.get(s.submitter_subject) })).join('')}</ul>` : '<p class="muted">The queue is empty.</p>'}</section>` : ''}`;
+ return shell({ config, path: '/robots/submissions', title: 'Community robots', description: 'Community robot profiles on OpenVibe.Bot.', body });
+}
+
+module.exports = { renderSubmit, renderSubmissions, renderDirectory, renderModels, renderModel, renderRobotPage, renderAddRobot, modelOf, sortModels, MODELS, CONNECT, CAPABILITY_WORDS };
@@ -40,7 +40,8 @@ const { renderPanel, renderEmbedRefused, renderDrivePage, renderPairingPage, ren
@@ -40,7 +40,8 @@ const { renderPanel, renderEmbedRefused, renderDrivePage, renderPairingPage, ren
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 { renderDirectory, renderModels, renderModel, renderRobotPage, renderAddRobot, renderSubmit, renderSubmissions, modelOf } = require('./directory');
+const { LICENSES } = require('../submissions');
const PUBLIC = path.join(__dirname, '..', '..', 'public');
const PUBLIC = path.join(__dirname, '..', '..', 'public');
const HOLD_RESEND_MS = 150;
const HOLD_RESEND_MS = 150;
@@ -123,11 +124,11 @@ const PANEL_CSP = ["default-src 'self'", `script-src 'self' ${PANEL_SCRIPT_SRC}`
@@ -123,11 +124,11 @@ const PANEL_CSP = ["default-src 'self'", `script-src 'self' ${PANEL_SCRIPT_SRC}`
const embedCsp = (config) => ["default-src 'self'", `script-src 'self' ${PANEL_SCRIPT_SRC}`, `connect-src 'self' ${PANEL_CONNECT_SRC}`,
const embedCsp = (config) => ["default-src 'self'", `script-src 'self' ${PANEL_SCRIPT_SRC}`, `connect-src 'self' ${PANEL_CONNECT_SRC}`,
`frame-ancestors ${frameAncestors(config)}`, "object-src 'none'", "base-uri 'self'"].join('; ');
`frame-ancestors ${frameAncestors(config)}`, "object-src 'none'", "base-uri 'self'"].join('; ');
-function createWebRoutes(config, { domain = null, sim = null, onvif = null, limits = null, identity = null, hub = null, log = console, releaseLastmod } = {}) {
+function createWebRoutes(config, { domain = null, sim = null, onvif = null, limits = null, identity = null, hub = null, log = console, submissions = null, releaseLastmod } = {}) {
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/new', 'Disallow: /pair/', 'Disallow: /panel/', 'Disallow: /install', `Sitemap: ${config.baseUrl}/sitemap.xml`, ''].join('\n')));
+ 'Disallow: /robots/new', 'Disallow: /robots/submit', 'Disallow: /robots/submissions', '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.
@@ -280,7 +281,13 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
@@ -280,7 +281,13 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
});
});
// ── The directory (plan T15 "a robot directory (/robots)"): public pages inside the OpenVibe Frame. ──
// ── 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));
+ // Every catalogue profile as a model; a community one carries its approved submission (who, driver, guide, key).
+ const catalogue = async () => {
+ const approved = submissions ? await submissions.community() : [];
+ const names = approved.length ? await namesOf(approved.map((s) => s.submitter_subject).filter(Boolean)) : new Map();
+ const by = new Map(approved.map((s) => [s.profile_id, { by: names.get(s.submitter_subject) || null, driver_url: s.driver_url, guide_url: s.guide_url, license: s.license, key_id: s.key_id }]));
+ return (await listProfiles(domain.db)).map((row) => modelOf(row.profile, by.get(row.profile.id) || null));
+ };
const namesOf = async (subjects) => {
const namesOf = async (subjects) => {
if (!identity || !subjects.length) return new Map();
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(); }
try { return await identity.names([...new Set(subjects)]); } catch (e) { log.warn(`[Bot] directory names: ${e.message}`); return new Map(); }
@@ -335,6 +342,57 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
@@ -335,6 +342,57 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
}
}
r.get('/robots/new', page(async (req, res) => { requireUser(req); return addPage(req, res); }));
r.get('/robots/new', page(async (req, res) => { requireUser(req); return addPage(req, res); }));
+ // ── Community robots (server/submissions.js): submit a profile, see where it stands; admins review. ──
+ const submitLimit = limits ? [(req, res, next) => { if (req.viewer && !req.principal) req.principal = req.viewer; next(); }, limits('bot.profile.submit', { minute: 20, hour: 60 })] : [];
+ const isReviewer = (me) => !!(me && me.role === 'admin');
+ r.get('/robots/submit', page(async (req, res) => {
+ requireUser(req);
+ res.setHeader('Content-Security-Policy', HOME_CSP);
+ res.type('html').send(renderSubmit({ config, licenses: LICENSES }));
+ }));
+ r.post('/robots/submit', ...submitLimit, express.urlencoded({ extended: false, limit: '48kb' }), page(async (req, res) => {
+ const me = requireUser(req);
+ if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');
+ if (!submissions) throw new BotError(503, 'bot.unavailable', 'submissions are not available');
+ const b = req.body || {};
+ const values = { profile: String(b.profile || ''), driver_url: String(b.driver_url || ''), guide_url: String(b.guide_url || ''), license: String(b.license || ''), notes: String(b.notes || '') };
+ let profile;
+ try { profile = JSON.parse(values.profile); } catch (e) {
+ res.status(422).setHeader('Content-Security-Policy', HOME_CSP);
+ return res.type('html').send(renderSubmit({ config, licenses: LICENSES, values, error: `The profile is not valid JSON: ${e.message}` }));
+ }
+ try {
+ await submissions.submit(me, { profile, driver_url: values.driver_url, guide_url: values.guide_url, license: values.license, notes: values.notes });
+ } catch (e) {
+ if (!e.status || e.status >= 500 || e.status === 401) throw e;
+ res.status(e.status).setHeader('Content-Security-Policy', HOME_CSP);
+ return res.type('html').send(renderSubmit({ config, licenses: LICENSES, values, error: e.detail || e.code }));
+ }
+ return res.redirect(303, '/robots/submissions');
+ }));
+ r.get('/robots/submissions', page(async (req, res) => {
+ const me = requireUser(req);
+ if (!submissions) throw new BotError(503, 'bot.unavailable', 'submissions are not available');
+ res.setHeader('Content-Security-Policy', HOME_CSP);
+ const pending = isReviewer(me) ? await submissions.pending() : null;
+ const names = pending && pending.length ? await namesOf(pending.map((s) => s.submitter_subject).filter(Boolean)) : new Map();
+ res.type('html').send(renderSubmissions({ config, mine: await submissions.mine(me.subject), pending, names, signing: submissions.signingKey() }));
+ }));
+ r.post('/robots/submissions/:id/review', ...manage, express.urlencoded({ extended: false, limit: '4kb' }), page(async (req, res) => {
+ const me = requireUser(req);
+ if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');
+ if (!submissions) throw new BotError(503, 'bot.unavailable', 'submissions are not available');
+ await submissions.review(req.params.id, me, { decision: String(req.body.decision || ''), note: req.body.note });
+ return res.redirect(303, '/robots/submissions');
+ }));
+ r.post('/robots/submissions/:id/withdraw', ...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');
+ if (!submissions) throw new BotError(503, 'bot.unavailable', 'submissions are not available');
+ await submissions.withdraw(req.params.id, me);
+ return res.redirect(303, '/robots/submissions');
+ }));
+
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);
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,126 @@
@@ -0,0 +1,126 @@
+'use strict';
+// Community robot profiles (plan T15: "people submit bots and guides and Bot connects them", reviewed and signed before
+// they run): submit at /robots/submit, see it at /robots/submissions, an admin approves (Bot signs it, Ed25519 over its
+// canonical JSON) or rejects with a note; an approved profile is in the catalogue (/models "From the community",
+// /robots/new, the API with its signature and Bot's public key) and robots can be added with it.
+const assert = require('assert');
+const crypto = require('crypto');
+const { boot, check, done } = require('./helpers/app');
+const { canonicalJson, loadKey } = require('../server/submissions');
+
+const { privateKey } = crypto.generateKeyPairSync('ed25519');
+const KEY = Buffer.from(privateKey.export({ type: 'pkcs8', format: 'pem' })).toString('base64');
+
+const PROFILE = {
+ id: 'community.ballbot', version: 1, name: 'Ballbot', vendor: 'Mia', kind: 'onboard',
+ description: 'A self-balancing ball robot on an ESP32, driven by a community Node plugin.',
+ capabilities: ['drive.differential', 'battery'],
+ mapping: { driver: 'plugin', plugin: 'ballbot' },
+ commands: { drive: { axes: { throttle: [-1, 1], steer: [-1, 1] } } },
+ widgets: [
+ { type: 'drive', capability: 'drive.differential', label: 'Drive', order: 1, command: { kind: 'drive' } },
+ { type: 'battery', capability: 'battery', label: 'Battery', order: 2 },
+ ],
+ limits: { max_speed: 0.5, max_turn: 0.6, max_command_ms: 300, heartbeat_ms: 1000 },
+};
+
+(async () => {
+ await check('canonical JSON sorts keys at every depth; the key loads from base64 PEM, raw PEM with \\n, or refuses', () => {
+ assert.strictEqual(canonicalJson({ b: 1, a: { d: [2, { y: 1, x: 2 }], c: 'é' } }), '{"a":{"c":"é","d":[2,{"x":2,"y":1}]},"b":1}');
+ const k = loadKey(KEY);
+ assert.match(k.keyId, /^[0-9a-f]{16}$/);
+ assert.strictEqual(loadKey(privateKey.export({ type: 'pkcs8', format: 'pem' }).replace(/\n/g, '\\n')).keyId, k.keyId);
+ assert.strictEqual(loadKey(''), null);
+ assert.throws(() => loadKey('bm90IGEga2V5'), /not a private key/);
+ const rsa = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey.export({ type: 'pkcs8', format: 'pem' });
+ assert.throws(() => loadKey(rsa), /Ed25519/);
+ });
+
+ const t = await boot({ env: { BOT_PROFILE_SIGNING_KEY: KEY } });
+ const mia = t.network.newUser('mia');
+ const sam = t.network.newUser('sam');
+ const ada = t.network.newUser('ada');
+ ada.role = 'admin';
+ const cookie = (u) => ({ Cookie: `ov_token=${t.network.signUser(u)}` });
+ const get = (p, u) => fetch(t.base + p, { redirect: 'manual', headers: u ? cookie(u) : {} });
+ const post = (p, u, form, headers = {}) => fetch(t.base + p, {
+ method: 'POST', redirect: 'manual', body: new URLSearchParams(form).toString(),
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...(u ? cookie(u) : {}), ...headers },
+ });
+ const submit = (u, profile, extra = {}, headers = {}) => post('/robots/submit', u, { profile: typeof profile === 'string' ? profile : JSON.stringify(profile), driver_url: 'https://github.com/mia/ballbot-driver', guide_url: 'https://openvibe.wiki/robots/ballbot', license: 'MIT', notes: 'Tested on two boards.', ...extra }, headers);
+
+ await check('/robots/submit: signed in only; a profile template; refusals come back on the form', async () => {
+ assert.strictEqual((await get('/robots/submit')).status, 302);
+ const form = await (await get('/robots/submit', mia)).text();
+ assert.ok(form.includes('name="profile"') && form.includes('community.my-robot') && form.includes('<option value="MIT"'));
+ const bad = await submit(mia, '{ not json');
+ assert.strictEqual(bad.status, 422);
+ assert.match(await bad.text(), /not valid JSON/);
+ const notCommunity = await submit(mia, { ...PROFILE, id: 'ballbot' });
+ assert.strictEqual(notCommunity.status, 422);
+ assert.match(await notCommunity.text(), /community\.<name>|community\.<name>/);
+ const noSource = await submit(mia, PROFILE, { driver_url: '' });
+ assert.strictEqual(noSource.status, 422, 'a plugin-driven robot needs its plugin\'s source');
+ const http = await submit(mia, PROFILE, { driver_url: 'http://example.test/x' });
+ assert.strictEqual(http.status, 422, 'https only');
+ const broken = await submit(mia, { ...PROFILE, mapping: { driver: 'plugin' } });
+ assert.strictEqual(broken.status, 422, 'a plugin driver names its plugin');
+ assert.strictEqual((await submit(mia, PROFILE, { license: 'WTFPL' })).status, 422);
+ assert.strictEqual((await submit(mia, PROFILE, {}, { Origin: 'https://evil.test' })).status, 403);
+ });
+
+ let subId;
+ await check('a good submission waits for review; someone else cannot take its id meanwhile', async () => {
+ const r = await submit(mia, PROFILE);
+ assert.strictEqual(r.status, 303);
+ assert.strictEqual(r.headers.get('location'), '/robots/submissions');
+ const row = await t.db.maybe("SELECT * FROM profile_submissions WHERE profile_id = 'community.ballbot' AND status = 'pending'");
+ assert.ok(row && row.submitter_subject === mia.subject && row.license === 'MIT' && row.driver_url === 'https://github.com/mia/ballbot-driver');
+ subId = row.id;
+ const mine = await (await get('/robots/submissions', mia)).text();
+ assert.ok(mine.includes('community.ballbot') && mine.includes('Waiting for review') && !mine.includes('Approve and sign'), 'not a reviewer');
+ assert.strictEqual((await submit(sam, PROFILE)).status, 409);
+ assert.strictEqual((await get('/api/v1/profiles/community.ballbot')).status, 404, 'not in the catalogue yet');
+ });
+
+ await check('only an admin reviews; a rejection needs a note', async () => {
+ assert.strictEqual((await post(`/robots/submissions/${subId}/review`, mia, { decision: 'approve' })).status, 403);
+ const queue = await (await get('/robots/submissions', ada)).text();
+ assert.ok(queue.includes('Waiting for review') && queue.includes('Approve and sign') && queue.includes('From @mia'));
+ assert.strictEqual((await post(`/robots/submissions/${subId}/review`, ada, { decision: 'reject' })).status, 422);
+ });
+
+ await check('approval signs it and puts it in the catalogue: API with signature, /models, /robots/new, a robot', async () => {
+ const r = await post(`/robots/submissions/${subId}/review`, ada, { decision: 'approve', note: 'Looks safe.' });
+ assert.strictEqual(r.status, 303);
+ const api = (await t.call('GET', '/api/v1/profiles/community.ballbot', { token: null })).json;
+ assert.strictEqual(api.profile.name, 'Ballbot');
+ const key = (await t.call('GET', '/api/v1/profiles/signing-key', { token: null })).json.key;
+ assert.strictEqual(key.alg, 'Ed25519');
+ assert.strictEqual(api.signature.key_id, key.key_id);
+ const ok = crypto.verify(null, Buffer.from(canonicalJson(api.profile)), crypto.createPublicKey(key.public_key_pem), Buffer.from(api.signature.value, 'base64'));
+ assert.ok(ok, 'the signature verifies with Bot\'s published key');
+ const models = await (await get('/models')).text();
+ assert.ok(models.includes('From the community') && models.includes('href="/models/community.ballbot"'));
+ const page = await (await get('/models/community.ballbot')).text();
+ assert.ok(page.includes('Submitted by @mia') && page.includes('https://github.com/mia/ballbot-driver') && page.includes(key.key_id));
+ assert.match(await (await get('/robots/new?profile=community.ballbot', sam)).text(), /value="community\.ballbot" checked/);
+ const made = await t.call('POST', '/api/v1/robots', { user: sam, body: { name: 'My ballbot', profile_id: 'community.ballbot' } });
+ assert.strictEqual(made.status, 201, made.text);
+ assert.strictEqual((await submit(sam, { ...PROFILE, version: 2 })).status, 409, 'the id is mia\'s now');
+ assert.strictEqual((await submit(mia, PROFILE)).status, 409, 'version 1 is taken: a new one needs version 2');
+ assert.strictEqual((await submit(mia, { ...PROFILE, version: 2, name: 'Ballbot 2' })).status, 303, 'its submitter sends a new version');
+ });
+
+ await check('account deletion: the person\'s waiting submissions go; the approved profile stays, authorless', async () => {
+ const accountData = require('../server/account-data').create({ domain: t.domain, log: { log() {}, warn() {} } });
+ const out = await accountData.erase([mia.subject]);
+ assert.strictEqual(out.erased.profile_submissions, 1, 'version 2, still waiting, is deleted');
+ const left = await t.db.many("SELECT status, submitter_subject FROM profile_submissions WHERE profile_id = 'community.ballbot'");
+ assert.deepStrictEqual(left.map((r) => [r.status, r.submitter_subject]), [['approved', null]]);
+ assert.strictEqual((await t.call('GET', '/api/v1/profiles/community.ballbot', { token: null })).status, 200, 'robots keep their profile');
+ });
+
+ await t.close();
+ done();
+})();