openvibers/openvibe.bot Commits

Add a kit catalogue and the Adeept kit entry, with a Get a robot section (#30)

OP OpenVibers committed 5 Oct 2026 · 45233562a7 · 10 files, +359 −11

* wip: kit catalogue backend

* Get a robot: the kit catalogue on the robots page (parts, build guide, one-click start with its profile chosen); compact embed switch in the list

* Kits: read the catalogue once and validate it at boot (review)

Changes

ddiff --git a/README.md b/README.md
iindex 3806ab7..c576c04 100644
---- a/README.md
++++ b/README.md
@@@ -9,6 +9,7 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato
## Owns
- Robots, their profiles and each robot's limits, `allow` lists, roles and operators.
++- The curated kit catalogue (`server/kits/*.json`, `GET /api/v1/kits`, `/kits/:id`): each kit's parts list and build guide, bound to a shipped profile (the Adeept ADR036 first). Metadata only — pricing, shipping and returns are the owner's (O29).
- Devices (the machines that serve a robot) and the credentials that identify them: hashed at rest, rotated and revoked, never readable back.
- Pairing: one-time codes with a short expiry, minted by Bot or, with `BOT_PAIRING_AUTHORITY=network`, by OpenVibe.Network.
- The `/device` and `/control` WebSockets, the command gate and the latched e-stop.
@@@ -52,7 +53,7 @@ A person acts on their own robots with a Network user token; a service acts with
## Tests
--`npm test` runs `test/run.js`, the whole suite. `npm run test:pg` (`BOT_TEST_STORE=pg`) runs the same suite against PostgreSQL; the default store is in-process PGlite. The tests cover the device and operator WebSocket gates, the REST routes, the pairing paths, the owner fence, the e-stop and the outbox.
++`npm test` runs `test/run.js`, the whole suite. `npm run test:pg` (`BOT_TEST_STORE=pg`) runs the same suite against PostgreSQL; the default store is in-process PGlite. The tests cover the device and operator WebSocket gates, the REST routes, the pairing paths, the profiles and the kit catalogue, the owner fence, the e-stop and the outbox.
## Security
ddiff --git a/STATUS.json b/STATUS.json
iindex f669539..8c200cb 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -33,6 +33,7 @@
"pairing with one-time codes, minted by Bot or delegated to OpenVibe.Network (BOT_PAIRING_AUTHORITY=network); a node machine binds on first use and reauths every 300 s",
"device credentials hashed at rest and rotatable; revoke closes the socket at once and revokes the Network principal for a node machine",
"profiles instead of per-robot rows: adeept.adr036 (+ mecanum variant), cozmo, camera.onvif, sim.rover, seeded from server/profiles/*.json",
++ "curated kit catalogue (server/kits/*.json; GET /api/v1/kits, /kits/:id): each kit's parts list and build guide, validated and bound to a shipped profile — the Adeept ADR036 first; metadata only (pricing, shipping and returns are O29)",
"a latched e-stop only the owner clears, with halt always allowed, and a device-side deadman deadline on the motion kinds",
"command audit kept 30 days, and the bot.robot.online/offline, bot.estop.set/cleared and bot.command.refused outbox events",
"public robot-profile catalogue (GET /profiles, /profiles/:id); robots are private to their owner, with public control only as a timed turn queue",
ddiff --git a/public/panel.css b/public/panel.css
iindex 6622335..caaa292 100644
---- a/public/panel.css
++++ b/public/panel.css
@@@ -220,3 +220,28 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf
.toggle b { display: block; font-weight: 600; }
.toggle small { display: block; margin-top: 0.15rem; color: var(--muted); font-size: 0.85rem; line-height: 1.4; }
.toggle:has(input:disabled) b, .toggle:has(input:disabled) small { opacity: 0.55; }
++
++/* "Get a robot": the curated kits under the add form. */
++.kits { margin-top: 1.75rem; }
++.kits > h2 { margin-bottom: 0.25rem; }
++.kits > .muted { margin: 0 0 0.85rem; }
++.kit-list { list-style: none; padding: 0; margin: 0; display: grid; gap: 0.75rem; }
++.kit { display: grid; gap: 0.6rem; }
++.kit-head { display: flex; gap: 0.75rem; align-items: flex-start; }
++.kit-icon { width: 2.5rem; height: 2.5rem; flex: none; padding: 0.45rem; border-radius: 10px; color: var(--accent); background: var(--raised); }
++.kit h3 { margin: 0 0 0.3rem; font-size: 1.05rem; line-height: 1.3; }
++.kit-desc { margin: 0; color: var(--muted); font-size: 0.92rem; }
++.kit-parts summary { cursor: pointer; font-weight: 600; min-height: var(--tap); display: flex; align-items: center; gap: 0.35rem; }
++.kit-parts ul { list-style: none; margin: 0.25rem 0 0; padding: 0; display: grid; gap: 0.35rem; }
++.kit-parts li { display: grid; grid-template-columns: 2.25rem minmax(0, 1fr); gap: 0.4rem; font-size: 0.92rem; }
++.kit-parts small { display: block; color: var(--muted); font-size: 0.82rem; }
++.part-qty { color: var(--muted); font-variant-numeric: tabular-nums; text-align: right; }
++.kit-actions { margin: 0; display: flex; flex-wrap: wrap; gap: 0.5rem; }
++.embed-inline { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; }
++.embed-inline .toggle { flex: 1 1 18rem; padding: 0.45rem 0.65rem; background: transparent; }
++.embed-inline .toggle small { font-size: 0.8rem; }
++.kit-parts summary { list-style: none; }
++.kit-parts summary::-webkit-details-marker { display: none; }
++.kit-parts summary::before { content: ''; width: 0.45rem; height: 0.45rem; margin: 0 0.4rem 0 0.15rem; border-right: 2px solid var(--muted); border-bottom: 2px solid var(--muted); transform: rotate(-45deg); transition: transform 0.12s; }
++.kit-parts[open] summary::before { transform: rotate(45deg); }
++@media (prefers-reduced-motion: reduce) { .kit-parts summary::before { transition: none; } }
ddiff --git a/server/api/v1.js b/server/api/v1.js
iindex 503e809..2a41d56 100644
---- a/server/api/v1.js
++++ b/server/api/v1.js
@@@ -9,6 +9,7 @@
* | Method & path | Capability (services) | People |
* |------------------------------------------------|------------------------|-------------------------------------|
* | GET /profiles, /profiles/:id | — (public) | anyone |
++ * | GET /kits, /kits/:id | — (public) | anyone |
* | GET /robots | bot.robot.read | own robots (?owner= only themself) |
* | POST /robots | bot.robot.manage | the owner (new robot + pairing code)|
* | GET /robots/:id | bot.robot.read | a member (owner/operator/viewer) |
@@@ -35,6 +36,7 @@ const express = require('express');
const { http } = require('openvibe-contracts');
const { fail, userSubject, isRobotId, json } = require('../util');
const { getProfile, listProfiles } = require('../profiles');
++const kits = require('../kits');
const CAP = {
read: 'bot.robot.read',
@@@ -131,6 +133,16 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config }) {
res.json({ profile: p.profile });
}));
++ // ── Kits (public read; the "Get a robot" catalogue, server/kits/*.json) ────────────────────────
++ r.get('/kits', wrap(async (req, res) => {
++ res.json({ kits: kits.list() });
++ }));
++ r.get('/kits/:id', wrap(async (req, res) => {
++ const kit = kits.get(req.params.id);
++ if (!kit) fail(404, 'bot.kit_not_found', 'no such kit');
++ res.json({ kit });
++ }));
++
// ── Robots ────────────────────────────────────────────────────────────────────────────────────
r.get('/robots', wrap(async (req, res) => {
requireRead(req);
ddiff --git a/server/index.js b/server/index.js
iindex 5be90ea..9fac354 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -28,6 +28,7 @@ async function main() {
const m = await migrate(config, { serving: db });
if (m.held.length) console.warn(`[Bot] migrations held: ${m.held.map((h) => `${h.id} (${h.reason})`).join('; ')}`);
await seedProfiles(db, { log: console });
++ require('./kits').catalogue(); // the kit catalogue is validated at boot: a bad server/kits/*.json stops it here
const valkey = createValkey({ url: config.valkey.url, prefix: config.valkey.prefix });
const app = createApp({ config, db, valkey, registry });
ddiff --git a/server/kits/adeept.adr036.json b/server/kits/adeept.adr036.json
nnew file mode 100644
iindex 0000000..0b15710
---- /dev/null
++++ b/server/kits/adeept.adr036.json
@@@ -0,0 +1,68 @@
++{
++ "id": "adeept.adr036",
++ "name": "Adeept ADR036 4WD Smart Car Kit for Raspberry Pi",
++ "vendor": "Adeept",
++ "description": "The first curated OpenVibe robot: the Adeept 4WD Smart Car with the Robot HAT — four DC motors and pan/tilt servos on a PCA9685, an ADS7830 battery readout, WS2812 lights, a buzzer, ultrasound and line tracking. The OpenVibe Node agent drives the HAT directly, so the kit's stock control server is never exposed.",
++ "profile_id": "adeept.adr036",
++ "build_guide_url": "https://www.adeept.com/learn/detail-97.html",
++ "parts": [
++ {
++ "name": "Adeept ADR036 4WD Smart Car Kit (acrylic chassis and hardware)",
++ "qty": 1,
++ "note": "Adeept's tutorial download page is the build guide"
++ },
++ {
++ "name": "Adeept Robot HAT (PCA9685 motor/servo driver, ADS7830 battery ADC)",
++ "qty": 1,
++ "note": "Comes with the kit; also charges the 18650 pack over USB-C"
++ },
++ {
++ "name": "TT DC motors",
++ "qty": 4
++ },
++ {
++ "name": "Wheels",
++ "qty": 4
++ },
++ {
++ "name": "AD002 pan/tilt servos",
++ "qty": 2
++ },
++ {
++ "name": "Raspberry Pi camera module",
++ "qty": 1
++ },
++ {
++ "name": "Ultrasonic range sensor",
++ "qty": 1
++ },
++ {
++ "name": "Line-tracking module",
++ "qty": 1
++ },
++ {
++ "name": "WS2812 RGB LED module",
++ "qty": 1
++ },
++ {
++ "name": "Buzzer",
++ "qty": 1,
++ "note": "On the Robot HAT"
++ },
++ {
++ "name": "Raspberry Pi 4B or 5 (2 GB or more)",
++ "qty": 1,
++ "note": "Not included in the Adeept kit"
++ },
++ {
++ "name": "microSD card, 16 GB or more",
++ "qty": 1,
++ "note": "Raspberry Pi OS Bookworm, 64-bit"
++ },
++ {
++ "name": "18650 Li-ion cells",
++ "qty": 2,
++ "note": "The Robot HAT charges the pack over USB-C"
++ }
++ ]
++}
ddiff --git a/server/kits/index.js b/server/kits/index.js
nnew file mode 100644
iindex 0000000..60c6d94
---- /dev/null
++++ b/server/kits/index.js
@@@ -0,0 +1,95 @@
++'use strict';
++
++/**
++ * Curated kit catalogue (plan T15 "Get a robot", the O29 metadata half): the kits a person can buy to get a
++ * robot online, each bound to a shipped profile and carrying its parts list and build guide. Kits live as
++ * server/kits/*.json and are validated here in the style of server/profiles/index.js — a typo or a kit bound
++ * to a profile that does not ship refuses at boot (server/index.js loads the catalogue), never at a store page.
++ *
++ * Metadata only: this is what to buy and how to build it. Pricing, shipping, returns and fulfilment are the
++ * owner's (O29), not modelled here, so nothing in this catalogue carries a price.
++ *
++ * Kit shape (bot.kit@1):
++ * { id, name, vendor?, description?,
++ * profile_id, // must name a shipped profile (loadKits checks it)
++ * build_guide_url, // https
++ * parts: [{ name, qty?, required?, note? }] } // qty defaults to 1, required defaults to true
++ *
++ * GET /api/v1/kits serves list()/get(); the robots page ("Get a robot") reads the same helper.
++ */
++const fs = require('fs');
++const path = require('path');
++const { BotError } = require('../util');
++const { loadProfiles } = require('../profiles');
++
++const DIR = path.join(__dirname);
++const ID_RE = /^[a-z][a-z0-9._-]{1,63}$/;
++const isObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
++
++function bad(why) { throw new BotError(500, 'bot.kit_invalid', why); }
++
++/** https only, so a catalogue entry can never smuggle a `javascript:` or plain-http link into a page. */
++function isGuideUrl(v) {
++ if (typeof v !== 'string' || !v.trim() || v.length > 300) return false;
++ try { const u = new URL(v); return u.protocol === 'https:' && !!u.hostname; } catch { return false; }
++}
++
++/** One line of a kit's parts list: what it is, how many, and whether the build needs it. */
++function validatePart(where, p) {
++ if (!isObject(p)) bad(`${where} must be an object`);
++ if (typeof p.name !== 'string' || !p.name.trim()) bad(`${where}.name is required`);
++ if (p.name.trim().length > 120) bad(`${where}.name must be at most 120 characters`);
++ const qty = p.qty == null ? 1 : p.qty;
++ if (!(Number.isInteger(qty) && qty > 0)) bad(`${where}.qty must be a positive integer`);
++ if (p.required != null && typeof p.required !== 'boolean') bad(`${where}.required must be true or false`);
++ if (p.note != null && (typeof p.note !== 'string' || p.note.trim().length > 200)) bad(`${where}.note must be at most 200 characters`);
++ return { name: p.name.trim(), qty, required: p.required !== false, ...(p.note && p.note.trim() ? { note: p.note.trim() } : {}) };
++}
++
++/**
++ * Validate one kit. `profiles` (a Map of id → profile) is optional: loadKits passes the shipped set so a kit
++ * bound to a profile that does not ship is refused; validateKit alone checks shape only.
++ */
++function validateKit(k, { profiles = null } = {}) {
++ if (!isObject(k)) bad('a kit must be an object');
++ if (typeof k.id !== 'string' || !ID_RE.test(k.id)) bad(`kit id is not valid: ${k.id}`);
++ if (typeof k.name !== 'string' || !k.name.trim()) bad(`${k.id}: name is required`);
++ if (k.name.trim().length > 120) bad(`${k.id}: name must be at most 120 characters`);
++ if (k.vendor != null && (typeof k.vendor !== 'string' || k.vendor.trim().length > 60)) bad(`${k.id}: vendor must be at most 60 characters`);
++ if (k.description != null && (typeof k.description !== 'string' || k.description.trim().length > 500)) bad(`${k.id}: description must be at most 500 characters`);
++ if (typeof k.profile_id !== 'string' || !ID_RE.test(k.profile_id)) bad(`${k.id}: profile_id is required`);
++ if (profiles && !profiles.has(k.profile_id)) bad(`${k.id}: profile_id ${k.profile_id} is not a shipped profile`);
++ if (!isGuideUrl(k.build_guide_url)) bad(`${k.id}: build_guide_url must be an https URL`);
++ if (!Array.isArray(k.parts) || !k.parts.length) bad(`${k.id}: parts must be a non-empty array`);
++ const parts = k.parts.map((p, i) => validatePart(`${k.id}: parts[${i}]`, p));
++ return {
++ id: k.id, name: k.name.trim(), vendor: k.vendor ? k.vendor.trim() : null,
++ description: k.description ? k.description.trim() : null,
++ profile_id: k.profile_id, build_guide_url: k.build_guide_url, parts,
++ };
++}
++
++/** Every kit in server/kits/*.json, validated and bound to a shipped profile. Throws on the first invalid file. */
++function loadKits(dir = DIR) {
++ const profiles = loadProfiles();
++ const files = fs.readdirSync(dir).filter((f) => f.endsWith('.json')).sort();
++ const out = new Map();
++ for (const f of files) {
++ let raw;
++ try { raw = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')); } catch (e) { bad(`${f}: ${e.message}`); }
++ const kit = validateKit(raw, { profiles });
++ out.set(kit.id, kit);
++ }
++ if (!out.size) bad('no kits found');
++ return out;
++}
++
++// The shipped catalogue is read once: server/index.js loads it at boot (a bad file stops the boot there), and
++// every list()/get() after that is a lookup, not a directory read.
++let shipped = null;
++const catalogue = () => (shipped || (shipped = loadKits()));
++/** The catalogue as the API and the robots page present it. */
++function list() { return [...catalogue().values()]; }
++function get(id) { return catalogue().get(id) || null; }
++
++module.exports = { validateKit, validatePart, loadKits, catalogue, list, get, DIR };
ddiff --git a/server/web/render.js b/server/web/render.js
iindex 28f590a..d017daa 100644
---- a/server/web/render.js
++++ b/server/web/render.js
@@@ -215,13 +215,13 @@ function renderWidget(w, { profile = {}, allowed_commands = [] } = {}) {
const toggle = (name, on, title, note, disabled = false, was = false) => `${was ? `<input type="hidden" name="${esc(name)}_was" value="${on ? 'on' : 'off'}">` : ''}<input type="hidden" name="${esc(name)}" value="off">
<label class="toggle"><input type="checkbox" name="${esc(name)}" value="on"${on ? ' checked' : ''}${disabled ? ' disabled' : ''}><span><b>${esc(title)}</b><small>${esc(note)}</small></span></label>`;
--/** The owner's embed switch: plain markup, works without JavaScript. */
--function embedForm(robot) {
-- return `<section class="setting-card" aria-labelledby="embed-h"><h2 id="embed-h">Embedding</h2>
--<form class="embed-form" method="post" action="/robots/${esc(robot.id)}/embed" data-embed-form>
++/** The owner's embed switch: plain markup, works without JavaScript. On the panel it is a card; in the robots list, a compact row. */
++function embedForm(robot, { card = true } = {}) {
++ const form = `<form class="embed-form${card ? '' : ' embed-inline'}" method="post" action="/robots/${esc(robot.id)}/embed" data-embed-form>
${toggle('embed_public', robot.embed_public, 'Let anyone watch where it is embedded', "A page that embeds this robot shows its video and readouts to everyone; never the controls.")}
<button type="submit">Save</button>
--</form></section>`;
++</form>`;
++ return card ? `<section class="setting-card" aria-labelledby="embed-h"><h2 id="embed-h">Embedding</h2>\n${form}</section>` : form;
}
// Why the streaming switches cannot be used right now, in the owner's words (domain.streaming reasons, plus
@@@ -290,15 +290,37 @@ function renderEmbedRefused({ robotId }) {
return page('Not shared', body, { bodyClass: 'embed-page' });
}
--function renderRobotsPage({ robots = [], profiles = [], error = null, values = {} }) {
++/**
++ * "Get a robot": the curated kits (server/kits), each with what it is, the parts to buy, its build guide and a
++ * one-click start that opens the add form with the kit's tested profile chosen. Metadata only: no prices or
++ * shop links until the owner settles supply (plan O29).
++ */
++function kitsSection(kits = [], profiles = []) {
++ const shipped = new Set(profiles.map((p) => p.id));
++ const usable = kits.filter((k) => shipped.has(k.profile_id));
++ if (!usable.length) return '';
++ const part = (p) => `<li><span class="part-qty">${esc(p.qty)}×</span><span>${esc(p.name)}${p.note ? `<small>${esc(p.note)}</small>` : ''}</span></li>`;
++ const card = (k) => {
++ const need = k.parts.filter((p) => p.required !== false).length;
++ return `<li class="kit card"><div class="kit-head">${icon('robot', 'icon kit-icon')}<div><h3>${esc(k.name)}</h3>${k.vendor ? `<span class="pill">${esc(k.vendor)}</span>` : ''}</div></div>
++<p class="kit-desc">${esc(k.description || '')}</p>
++<details class="kit-parts"><summary>What you need <span class="muted">(${esc(need)} ${need === 1 ? 'part' : 'parts'})</span></summary><ul>${k.parts.map(part).join('')}</ul></details>
++<p class="kit-actions"><a class="button primary" href="/robots?profile=${encodeURIComponent(k.profile_id)}#add-robot">Add this robot</a> ${outLink(k.build_guide_url, 'Build guide', 'button quiet')}</p></li>`;
++ };
++ return `<section class="kits" aria-labelledby="kits-h"><h2 id="kits-h">Get a robot</h2>
++<p class="muted">Curated kits with a ready-made profile: build one, pair it, and its panel already knows every motor and sensor.</p>
++<ul class="kit-list">${usable.map(card).join('')}</ul></section>`;
++}
++
++function renderRobotsPage({ robots = [], profiles = [], kits = [], error = null, values = {} }) {
const names = new Map(profiles.map((p) => [p.id, p.name]));
const list = robots.length
? `<ul class="robots">${robots.map((r) => `<li class="robot-card"><a class="robot-name" href="/panel/${esc(r.id)}">${icon('robot')}<span>${esc(r.name)}</span></a>`
+ `<span class="robot-meta"><span class="pill">${esc(names.get(r.profile_id) || r.profile_id)}</span> <span class="pill">${esc(POLICY_LABEL[r.access_policy] || r.access_policy)}</span></span>`
-- + `<span class="robot-actions"><a class="button" href="/panel/${esc(r.id)}">Open panel</a> <a class="button quiet pair" href="/pair/${esc(r.id)}">${icon('plug')}<span>Pair a device</span></a></span>${embedForm(r)}</li>`).join('')}</ul>`
++ + `<span class="robot-actions"><a class="button" href="/panel/${esc(r.id)}">Open panel</a> <a class="button quiet pair" href="/pair/${esc(r.id)}">${icon('plug')}<span>Pair a device</span></a></span>${embedForm(r, { card: false })}</li>`).join('')}</ul>`
: `<div class="empty">${icon('robot', 'icon empty-icon')}<p class="empty-title">No robots yet.</p><p>Add one below. A simulated rover needs no hardware and drives straight away.</p></div>`;
const option = (v, label, selected) => `<option value="${esc(v)}"${selected ? ' selected' : ''}>${esc(label)}</option>`;
-- const form = `<form class="add-robot card" method="post" action="/robots">
++ const form = `<form class="add-robot card" id="add-robot" method="post" action="/robots">
<h2>Add a robot</h2>
${error ? `<p class="error" role="alert">${esc(error)}</p>` : ''}
<label class="field"><span class="field-label">Name</span><input type="text" name="name" maxlength="80" required autocomplete="off" placeholder="e.g. Kitchen rover" value="${esc(values.name || '')}"></label>
@@@ -306,7 +328,7 @@ ${error ? `<p class="error" role="alert">${esc(error)}</p>` : ''}
<label class="field"><span class="field-label">Who may drive</span><select name="access_policy">${POLICIES.map(([v, label]) => option(v, label, v === (values.access_policy || 'private'))).join('')}</select></label>
<button type="submit" class="primary">Add robot</button>
</form>`;
-- return page('Your robots', `${topbar()}\n<main class="page">\n<h1>Your robots</h1>\n${list}\n${form}\n</main>`);
++ return page('Your robots', `${topbar()}\n<main class="page">\n<h1>Your robots</h1>\n${list}\n${form}\n${kitsSection(kits, profiles)}\n</main>`);
}
function renderPairingPage({ robot, pairing, profile = null }) {
ddiff --git a/server/web/routes.js b/server/web/routes.js
iindex 24a7826..1da60b1 100644
---- a/server/web/routes.js
++++ b/server/web/routes.js
@@@ -26,6 +26,7 @@ const express = require('express');
const { getProfile, listProfiles } = require('../profiles');
const { frameAncestors } = require('../config');
const { BotError } = require('../util');
++const kits = require('../kits');
const { renderPanel, renderEmbedRefused, renderRobotsPage, renderPairingPage } = require('./render');
const VERSION = require('../../package.json').version;
@@@ -82,7 +83,10 @@ function createWebRoutes(config, { domain = null, sim = null, limits = null, log
async function robotsPage(req, res, me, extra = {}) {
const robots = (await domain.robots.list(me.subject)).map(presentForPage);
const profiles = (await listProfiles(domain.db)).map((p) => p.profile);
-- res.type('html').send(renderRobotsPage({ robots, profiles, ...extra }));
++ // A kit's "Add this robot" arrives as ?profile=<id>: the add form opens with that model chosen.
++ const asked = typeof req.query.profile === 'string' && profiles.some((p) => p.id === req.query.profile) ? req.query.profile : null;
++ const values = extra.values || (asked ? { profile_id: asked } : {});
++ res.type('html').send(renderRobotsPage({ robots, profiles, kits: kits.list(), ...extra, values }));
}
r.get('/robots', page(async (req, res) => robotsPage(req, res, requireUser(req))));
ddiff --git a/test/kits.test.js b/test/kits.test.js
nnew file mode 100644
iindex 0000000..be4a25f
---- /dev/null
++++ b/test/kits.test.js
@@@ -0,0 +1,119 @@
++'use strict';
++// The kit catalogue (plan T15 "Get a robot", O29 metadata half): the shipped kits validate, every kit binds
++// to a profile that ships, an invalid catalogue refuses at load, and GET /kits is a public read.
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const { boot, check, done } = require('./helpers/app');
++const { loadKits, validateKit, list, get } = require('../server/kits');
++const { loadProfiles } = require('../server/profiles');
++
++// A minimal valid kit, cloned into the failure cases below.
++const MINIMAL = {
++ id: 'x.y', name: 'X', profile_id: 'sim.rover', build_guide_url: 'https://example.test/guide',
++ parts: [{ name: 'A board' }],
++};
++
++(async () => {
++ await check('the shipped kit catalogue loads, validates and binds every kit to a shipped profile', () => {
++ const kits = loadKits();
++ assert.deepStrictEqual([...kits.keys()], ['adeept.adr036']);
++ const profiles = loadProfiles();
++ for (const kit of kits.values()) {
++ assert.ok(profiles.has(kit.profile_id), `${kit.id} binds a shipped profile`);
++ assert.ok(kit.parts.length > 0, `${kit.id} has parts`);
++ assert.match(kit.build_guide_url, /^https:\/\//);
++ }
++ });
++
++ await check('the Adeept kit is the ADR036 car on adeept.adr036, with the parts you need and its build guide', () => {
++ const kit = get('adeept.adr036');
++ assert.strictEqual(kit.name, 'Adeept ADR036 4WD Smart Car Kit for Raspberry Pi');
++ assert.strictEqual(kit.profile_id, 'adeept.adr036');
++ assert.strictEqual(kit.vendor, 'Adeept');
++ assert.match(kit.build_guide_url, /^https:\/\//);
++ const names = kit.parts.map((p) => p.name).join('\n');
++ assert.match(names, /Robot HAT/);
++ assert.match(names, /Raspberry Pi/);
++ assert.match(names, /microSD/);
++ assert.match(names, /18650/);
++ for (const p of kit.parts) {
++ assert.ok(Number.isInteger(p.qty) && p.qty > 0, `${p.name} qty`);
++ assert.strictEqual(typeof p.required, 'boolean', `${p.name} required`);
++ }
++ const pi = kit.parts.find((p) => /Raspberry Pi 4B or 5/.test(p.name));
++ assert.match(pi.note, /not included/i, 'the kit does not include the Pi board');
++ assert.strictEqual(list().length, 1, 'list() is the same catalogue the API serves');
++ assert.strictEqual(get('nope'), null);
++ });
++
++ await check('qty defaults to 1 and required to true; a note is optional', () => {
++ const kit = validateKit({ ...MINIMAL });
++ assert.deepStrictEqual(kit.parts, [{ name: 'A board', qty: 1, required: true }]);
++ const withNote = validateKit({ ...MINIMAL, parts: [{ name: 'A board', qty: 2, required: false, note: 'spare' }] });
++ assert.deepStrictEqual(withNote.parts, [{ name: 'A board', qty: 2, required: false, note: 'spare' }]);
++ assert.strictEqual(withNote.vendor, null);
++ assert.strictEqual(withNote.description, null);
++ });
++
++ await check('a kit with a bad id, name, URL, parts or part is refused', () => {
++ const refused = [
++ [{ ...MINIMAL, id: 'Bad Id' }, /kit id is not valid/],
++ [{ ...MINIMAL, id: undefined }, /kit id is not valid/],
++ [{ ...MINIMAL, name: ' ' }, /name is required/],
++ [{ ...MINIMAL, profile_id: undefined }, /profile_id is required/],
++ [{ ...MINIMAL, build_guide_url: 'http://example.test/guide' }, /build_guide_url must be an https URL/],
++ [{ ...MINIMAL, build_guide_url: 'javascript:alert(1)' }, /build_guide_url must be an https URL/],
++ [{ ...MINIMAL, parts: [] }, /parts must be a non-empty array/],
++ [{ ...MINIMAL, parts: [{ qty: 1 }] }, /\.name is required/],
++ [{ ...MINIMAL, parts: [{ name: 'A board', qty: 0 }] }, /qty must be a positive integer/],
++ [{ ...MINIMAL, parts: [{ name: 'A board', note: 'x'.repeat(201) }] }, /note must be at most/],
++ ];
++ for (const [kit, re] of refused) assert.throws(() => validateKit(kit), re, JSON.stringify(kit));
++ });
++
++ await check('a kit bound to a profile that does not ship is refused at load', () => {
++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bot-kits-'));
++ try {
++ fs.writeFileSync(path.join(dir, 'orphan.json'), JSON.stringify({ ...MINIMAL, profile_id: 'does.not.exist' }));
++ assert.throws(() => loadKits(dir), /is not a shipped profile/);
++ // The shipped catalogue itself always passes the same check.
++ assert.doesNotThrow(() => loadKits());
++ } finally {
++ fs.rmSync(dir, { recursive: true, force: true });
++ }
++ });
++
++ const t = await boot();
++ await check('GET /api/v1/kits is a public read; an unknown kit is a problem+json 404', async () => {
++ const all = await t.call('GET', '/api/v1/kits', { token: null });
++ assert.strictEqual(all.status, 200);
++ assert.deepStrictEqual(all.json.kits.map((k) => k.id), ['adeept.adr036']);
++ const one = await t.call('GET', '/api/v1/kits/adeept.adr036', { token: null });
++ assert.strictEqual(one.status, 200);
++ assert.strictEqual(one.json.kit.profile_id, 'adeept.adr036');
++ assert.ok(one.json.kit.parts.length > 0);
++ const missing = await t.call('GET', '/api/v1/kits/nope', { token: null });
++ assert.strictEqual(missing.status, 404);
++ assert.strictEqual(missing.headers.get('content-type'), 'application/problem+json');
++ assert.strictEqual(missing.json.code, 'bot.kit_not_found');
++ });
++
++ await check('the robots page offers "Get a robot": the kit, its parts, its guide and a start with its profile chosen', async () => {
++ const alex = t.network.newUser('alex');
++ const page = (q = '') => fetch(`${t.base}/robots${q}`, { redirect: 'manual', headers: { Cookie: `ov_token=${t.network.signUser(alex)}` } }).then((r) => r.text());
++ const html = await page();
++ assert.match(html, /<h2 id="kits-h">Get a robot<\/h2>/);
++ assert.match(html, /Adeept ADR036 4WD Smart Car Kit for Raspberry Pi/);
++ assert.match(html, /What you need/);
++ assert.match(html, /href="https:\/\/www\.adeept\.com\/learn\/detail-97\.html" target="_blank" rel="noopener">Build guide/);
++ assert.match(html, /href="\/robots\?profile=adeept\.adr036#add-robot">Add this robot/);
++ assert.doesNotMatch(html, /\(O30\)/, 'no internal plan codes in public copy');
++ assert.match(await page('?profile=adeept.adr036'), /<option value="adeept\.adr036" selected>/, 'the kit start chooses its profile');
++ assert.doesNotMatch(await page('?profile=nope'), /<option value="nope"/);
++ });
++ await t.close();
++
++ done();
++})();