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)

OP OpenVibers committed 14 Oct 2026 · 1ceb2368e5 · 16 files, +560 −28

* Bot: community robots: anyone submits a profile with its driver and guide; an admin reviews, Bot signs it and it joins the catalogue

Plan T15 asks that "people should be able to submit bots and guides and have Bot connect them", with "community-submitted profiles and drivers … reviewed and signed before they run on anyone's Node". Until now a new robot type needed a pull request to Bot (and, for a new driver, a Contracts release, as LOOI did).

**Submit** (`/robots/submit`, signed in, inside the Frame)
- A profile JSON (a template is filled in), the driver's source, a setup guide (both https), a licence and notes for the reviewer.
- The id must be `community.<name>`. A robot run by its owner's own OpenVibe Node plugin uses `"driver": "plugin"` and names it in `"plugin"` (Contracts 0.160.0, pinned here).
- The profile is checked by the same validator as `/docs/profiles`. Refusals come back on the form, with the person's input kept.
- Ids:
  - Another person cannot take an id that is in the catalogue or waiting for review.
  - Only the original submitter sends a newer version, and it must be a higher version.
  - A new submission replaces the same person's pending one.
- Limited per person (20 a minute, 60 an hour).

**Review** (`/robots/submissions`)
- Everyone sees their own submissions and where each stands, and can withdraw a pending one.
- An OpenVibe admin (`role: admin`) also sees the queue, each with the full profile, links and notes.
- **Approve and sign** re-validates the profile, signs it (Ed25519 over its canonical JSON, with `BOT_PROFILE_SIGNING_KEY`) and inserts it into `robot_profiles` in one transaction. **Reject** needs a note.
- With no key configured, approvals are refused and the page says why. A key that does not load is logged and never stops the service.

**In the catalogue**
- `/models` has a **From the community** group.
- A community model's page credits the submitter and links the driver, the guide, the signature and the key, with a note that Bot never runs the driver itself.
- `/robots/new` offers it, and robots can be created with it.
- API:
  - `GET /api/v1/profiles/:id` carries `signature: { alg, key_id, value }` for a community profile.
  - `GET /api/v1/profiles/signing-key` publishes Bot's public key.
- Anyone (a Node, a mirror) can verify a profile against that key.

**Data** (migration `0010_profile_submissions`, expand)
- The submissions table, with an index on status and a unique approved `(profile_id, version)`.
- ADR-033: a person's submissions are in their export. Deletion removes the ones never approved, and keeps approved ones (robots use them) with the author cleared. The reviewer's id is cleared too.

**Driver validation:** `mapping.driver: plugin` requires `mapping.plugin` (lower case letters, digits, `-`, `_`).

**Tests**
- New `test/community.test.js`:
  - canonical JSON;
  - key loading (base64 PEM, raw PEM with `\n`, refusals including RSA);
  - the form's refusals: not JSON, no `community.` prefix, plugin without source, http links, plugin without a name, unknown licence, cross-site;
  - pending and id rivalry;
  - admin-only review, and reject needs a note;
  - approval: the signature verifies with the published key; `/models`, the model page, `/robots/new` and a robot created via the API;
  - versioning rules;
  - account deletion.
- The full suite passes: 36/36 test files.
- Checked in headless Chrome: `/robots/submit` and the admin review queue at desktop and phone widths.

* Bot: the submit form's template is a JSON file (its robot capabilities are not Network capability ids for the contracts check)

Changes

ddiff --git a/README.md b/README.md
iindex 1d96900..e45bef0 100644
---- a/README.md
++++ b/README.md
@@@ -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 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.
<!-- versions:start -->
--- openvibe-contracts: v0.157.0
++- openvibe-contracts: v0.160.0
- openvibe-sdk: v0.44.0
- openvibe-shared: v4.1.3
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex 83da7c2..7b76dfb 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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",
ddiff --git a/migrations/0010_profile_submissions.sql b/migrations/0010_profile_submissions.sql
nnew file mode 100644
iindex 0000000..6714bc9
---- /dev/null
++++ b/migrations/0010_profile_submissions.sql
@@@ -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';
ddiff --git a/package-lock.json b/package-lock.json
iindex 4ff86e1..fafe825 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex 7ff2eaa..66d6faf 100644
---- a/package.json
++++ b/package.json
@@@ -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",
ddiff --git a/public/directory.css b/public/directory.css
iindex 91c8320..57df029 100644
---- a/public/directory.css
++++ b/public/directory.css
@@@ -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); }
ddiff --git a/server/account-data.js b/server/account-data.js
iindex 21d5bba..152eda0 100644
---- a/server/account-data.js
++++ b/server/account-data.js
@@@ -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) {
ddiff --git a/server/api/v1.js b/server/api/v1.js
iindex a485c83..9847ddc 100644
---- a/server/api/v1.js
++++ b/server/api/v1.js
@@@ -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.
ddiff --git a/server/app.js b/server/app.js
iindex 330e315..02717e2 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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;
}
ddiff --git a/server/config.js b/server/config.js
iindex 8e2bc2c..87f5248 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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
ddiff --git a/server/profiles/index.js b/server/profiles/index.js
iindex b970f23..c484465 100644
---- a/server/profiles/index.js
++++ b/server/profiles/index.js
@@@ -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,
ddiff --git a/server/submissions.js b/server/submissions.js
nnew file mode 100644
iindex 0000000..8e3a80a
---- /dev/null
++++ b/server/submissions.js
@@@ -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 };
ddiff --git a/server/web/community-template.json b/server/web/community-template.json
nnew file mode 100644
iindex 0000000..8f38837
---- /dev/null
++++ b/server/web/community-template.json
@@@ -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
++ }
++}
ddiff --git a/server/web/directory.js b/server/web/directory.js
iindex 92e7aa8..436dfc4 100644
---- a/server/web/directory.js
++++ b/server/web/directory.js
@@@ -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.&lt;name&gt;</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 };
ddiff --git a/server/web/routes.js b/server/web/routes.js
iindex 57e8697..b1f3fb3 100644
---- a/server/web/routes.js
++++ b/server/web/routes.js
@@@ -40,7 +40,8 @@ const { renderPanel, renderEmbedRefused, renderDrivePage, renderPairingPage, ren
const { renderDocsIndex, renderDriversPage, renderProfilesPage } = require('./docs');
const { checkProfile } = require('../profiles');
const { renderHome, HOME_CSP } = require('./home');
--const { renderDirectory, renderModels, renderModel, renderRobotPage, renderAddRobot, modelOf } = require('./directory');
++const { 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');
ddiff --git a/test/community.test.js b/test/community.test.js
nnew file mode 100644
iindex 0000000..88ef897
---- /dev/null
++++ b/test/community.test.js
@@@ -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\.&lt;name&gt;|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();
++})();