openvibers/openvibe.bot Commits

A robot from a blank microSD card: /setup/sd-card makes the card's first-boot files in the browser, and the card adds the robot by itself with a setup key (no screen, keyboard or code) (#152)

OP OpenVibers committed 20 days ago · 64e9c537b9 · 14 files, +777 −12

Changes

ddiff --git a/migrations/0020_device_setup_keys.sql b/migrations/0020_device_setup_keys.sql
nnew file mode 100644
iindex 0000000..f9f07fb
---- /dev/null
++++ b/migrations/0020_device_setup_keys.sql
@@@ -0,0 +1,24 @@
++-- phase: expand
++-- A microSD card made on /setup/sd-card carries a setup key (owner 2026-10-24: a fresh card should not need a screen,
++-- a keyboard or a command). On first boot the installer starts a claim with it (`install.sh --setup <key>`), and the
++-- claim is approved at once for the key's owner with the choices made with the card files (server/setup-keys.js). The
++-- key lasts until its robot has paired, or 7 days; a retry before that pairs the same robot again. Only its hash is kept.
++CREATE TABLE IF NOT EXISTS device_setup_keys (
++ id text PRIMARY KEY, -- osk_…
++ owner_subject text NOT NULL,
++ secret_hash text NOT NULL UNIQUE, -- sha256 of the key written on the card
++ name text NOT NULL, -- the robot's name
++ profile_id text NOT NULL, -- its model
++ access_policy text NOT NULL DEFAULT 'private',
++ want_live boolean NOT NULL DEFAULT false, -- its camera on the owner's OpenVibe.Live channel once paired
++ hostname text, -- the hostname written on the card (what the person will ssh to)
++ robot_id text, -- the robot the first claim made
++ claim_id text, -- the last claim the key started
++ created_at timestamptz NOT NULL,
++ expires_at timestamptz NOT NULL,
++ claimed_at timestamptz, -- the device first asked with it
++ paired_at timestamptz, -- its robot paired: the key is spent
++ revoked_at timestamptz
++);
++CREATE INDEX IF NOT EXISTS device_setup_keys_owner_idx ON device_setup_keys (owner_subject, created_at);
++CREATE INDEX IF NOT EXISTS device_setup_keys_robot_idx ON device_setup_keys (robot_id) WHERE robot_id IS NOT NULL;
ddiff --git a/public/directory.css b/public/directory.css
iindex 57df029..f53d8a7 100644
---- a/public/directory.css
++++ b/public/directory.css
@@@ -92,3 +92,33 @@
.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); }
++
++/* /setup/sd-card: a robot from a blank microSD card (server/web/sd-card.js, public/sd-card.js). */
++.sd-list { margin: 0; padding-left: 20px; display: grid; gap: 6px; max-width: 760px; }
++.sd-steps { list-style: none; counter-reset: sd-step; margin: 0 0 30px; padding: 0; display: grid; gap: 18px; max-width: 860px; }
++.sd-step { counter-increment: sd-step; position: relative; padding: 18px 20px 18px 64px; border: 1px solid var(--border, #1f2d47); border-radius: 14px; background: var(--bg-secondary, #101828); }
++.sd-step::before { content: counter(sd-step); position: absolute; left: 18px; top: 16px; width: 32px; height: 32px; border-radius: 50%; display: grid; place-items: center; font-weight: 800; background: var(--accent-strong, var(--accent, #3472d8)); color: var(--on-accent-strong, #fff); }
++.sd-step > h2 { font-size: 19px; letter-spacing: -.01em; margin: 4px 0 12px; }
++.sd-sub { margin: 0; padding-left: 20px; display: grid; gap: 8px; }
++.sd-legend { font-weight: 700; margin: 4px 0 2px; }
++.sd-form input[type="password"] { 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); }
++.sd-form input[type="password"]:focus { outline: 2px solid var(--accent, #3b82f6); outline-offset: 1px; }
++.sd-check { display: flex; gap: 10px; align-items: flex-start; max-width: 640px; }
++.sd-check input { margin-top: 4px; accent-color: var(--accent, #3b82f6); }
++.sd-more summary { cursor: pointer; font-weight: 650; margin: 6px 0; }
++.sd-more[open] { display: grid; gap: 10px; }
++.sd-private { margin: 0; padding: 12px 14px; border-radius: 10px; border: 1px solid color-mix(in srgb, var(--accent, #3b82f6) 35%, transparent); background: color-mix(in srgb, var(--accent, #3b82f6) 8%, transparent); max-width: 760px; }
++.sd-signin { margin: 0; }
++.sd-dl { display: flex; flex-wrap: wrap; gap: 10px; margin: 0 0 12px; }
++.sd-status { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin: 12px 0 0; padding: 12px 14px; border-radius: 10px; border: 1px solid var(--border, #1f2d47); background: var(--bg-primary, #0a0f1c); }
++.sd-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--text-secondary, #96a7c2); flex: none; }
++.sd-status[data-state="waiting"] .sd-dot, .sd-status[data-state="installing"] .sd-dot { background: #f59e0b; animation: sd-pulse 1.4s ease-in-out infinite; }
++.sd-status[data-state="paired"] { border-color: rgba(34, 197, 94, .5); }
++.sd-status[data-state="paired"] .sd-dot { background: #22c55e; }
++.sd-status[data-state="expired"] .sd-dot, .sd-status[data-state="revoked"] .sd-dot { background: #ef4444; }
++@keyframes sd-pulse { 50% { opacity: .35; } }
++@media (prefers-reduced-motion: reduce) { .sd-status .sd-dot { animation: none !important; } }
++@media (max-width: 560px) { .sd-step { padding: 56px 16px 16px; } .sd-step::before { left: 16px; top: 14px; } }
++/* hidden wins over .dir-btn's display (the panel link waits for a robot, the downloads for the files). */
++.sd [hidden] { display: none !important; }
++.sd-made { margin: 0; padding: 12px 14px; border-radius: 10px; border: 1px solid rgba(34, 197, 94, .45); background: rgba(34, 197, 94, .08); }
ddiff --git a/public/sd-card.js b/public/sd-card.js
nnew file mode 100644
iindex 0000000..90854eb
---- /dev/null
++++ b/public/sd-card.js
@@@ -0,0 +1,289 @@
++/* openvibe.bot /setup/sd-card: a Raspberry Pi's first-boot files for a fresh microSD card, made in this browser.
++ *
++ * Raspberry Pi OS (Trixie images since November 2025) sets itself up on first boot from cloud-init files on the card's
++ * boot partition, the drive any computer sees: `user-data` (the login, SSH, the hostname, and here one command that adds
++ * the robot to openvibe.bot) and `network-config` (Wi-Fi). Raspberry Pi Imager writes them too, but appending to its
++ * user-data would break it (a second runcmd: replaces the first), so this page writes both whole files instead.
++ *
++ * The Wi-Fi password and the login password never leave this page: the password is hashed here (SHA-512 crypt, what
++ * Imager writes too) and both go only into the files you download. openvibe.bot is sent the robot's name, model, who
++ * may drive, Live and the hostname, and answers a setup key for the card (server/setup-keys.js).
++ *
++ * In Node (tests) this file exports the pure parts: sha512Crypt, yamlQuote, userData, networkConfig.
++ */
++(function (root, factory) {
++ 'use strict';
++ const api = factory();
++ if (typeof module === 'object' && module.exports) module.exports = api;
++ else if (typeof document !== 'undefined') document.addEventListener('DOMContentLoaded', () => api.init(document));
++}(typeof self !== 'undefined' ? self : this, function () {
++ 'use strict';
++
++ const subtle = () => (typeof crypto !== 'undefined' && crypto.subtle) || require('crypto').webcrypto.subtle;
++ const enc = (s) => new TextEncoder().encode(s);
++ const concat = (parts) => {
++ const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
++ let at = 0;
++ for (const p of parts) { out.set(p, at); at += p.length; }
++ return out;
++ };
++ const sha512 = async (bytes) => new Uint8Array(await subtle().digest('SHA-512', bytes));
++ /** `bytes` repeated to `len` bytes. */
++ const stretch = (bytes, len) => { const out = new Uint8Array(len); for (let i = 0; i < len; i++) out[i] = bytes[i % bytes.length]; return out; };
++
++ const ITOA64 = './0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
++ // SHA-512 crypt's output order (Drepper's specification): 21 groups of three bytes, then the last byte alone.
++ const ORDER = [[0, 21, 42], [22, 43, 1], [44, 2, 23], [3, 24, 45], [25, 46, 4], [47, 5, 26], [6, 27, 48], [28, 49, 7], [50, 8, 29], [9, 30, 51],
++ [31, 52, 10], [53, 11, 32], [12, 33, 54], [34, 55, 13], [56, 14, 35], [15, 36, 57], [37, 58, 16], [59, 17, 38], [18, 39, 60], [40, 61, 19], [62, 20, 41]];
++
++ /**
++ * crypt(3)'s SHA-512 scheme ($6$, 5000 rounds): what /etc/shadow and cloud-init's `passwd:` take. The same as
++ * `openssl passwd -6 -salt <salt> <password>` (test/sd-card.test.js checks it against that).
++ */
++ async function sha512Crypt(password, salt) {
++ const P = enc(password);
++ const S = enc(String(salt).slice(0, 16));
++ const B = await sha512(concat([P, S, P]));
++ const a = [P, S, stretch(B, P.length)];
++ for (let n = P.length; n > 0; n >>= 1) a.push(n & 1 ? B : P);
++ const A = await sha512(concat(a));
++ const DP = await sha512(concat(Array.from({ length: P.length }, () => P)));
++ const Pp = stretch(DP, P.length);
++ const DS = await sha512(concat(Array.from({ length: 16 + A[0] }, () => S)));
++ const Sp = stretch(DS, S.length);
++ let C = A;
++ for (let i = 0; i < 5000; i++) {
++ const parts = [i & 1 ? Pp : C];
++ if (i % 3) parts.push(Sp);
++ if (i % 7) parts.push(Pp);
++ parts.push(i & 1 ? C : Pp);
++ C = await sha512(concat(parts));
++ }
++ let out = '';
++ const b64 = (b2, b1, b0, n) => { let w = (b2 << 16) | (b1 << 8) | b0; for (let i = 0; i < n; i++) { out += ITOA64[w & 0x3f]; w >>= 6; } };
++ for (const [x, y, z] of ORDER) b64(C[x], C[y], C[z], 4);
++ b64(0, 0, C[63], 2);
++ return `$6$${String(salt).slice(0, 16)}$${out}`;
++ }
++
++ /** A salt of 16 crypt characters from the browser's random source. */
++ function newSalt() {
++ const bytes = new Uint8Array(16);
++ (typeof crypto !== 'undefined' && crypto.getRandomValues ? crypto : require('crypto').webcrypto).getRandomValues(bytes);
++ return Array.from(bytes, (b) => ITOA64[b & 0x3f]).join('');
++ }
++
++ /** A YAML double-quoted scalar: backslash and quote escaped, control characters as \xHH (as Imager does). */
++ function yamlQuote(value) {
++ return `"${String(value).replace(/\r?\n$/, '').replace(/[\\"\u0000-\u001f\u007f]/g, (c) => (c === '\\' ? '\\\\' : c === '"' ? '\\"'
++ : `\\x${c.charCodeAt(0).toString(16).padStart(2, '0')}`))}"`;
++ }
++
++ /**
++ * user-data. Mirrors what Raspberry Pi Imager 2 writes (its customization_generator.cpp): the `user:` block, avahi
++ * so <hostname>.local answers, SSH on, I2C and SPI for the robot's boards. Then the one thing Imager cannot do: a
++ * first-boot service that waits for the network and runs openvibe.bot's installer with the card's setup key, so the
++ * robot adds itself with nothing typed on it. Its output: `journalctl -u openvibe-setup`.
++ */
++ function userData({ hostname, username, passwordHash, sshKeys = [], timezone = '', setupKey, server = 'https://openvibe.bot', robotName = '' }) {
++ if (!/^osk_[A-Za-z0-9_-]{43}$/.test(setupKey || '')) throw new Error('no setup key');
++ if (!/^https?:\/\/[A-Za-z0-9.-]+(:\d+)?$/.test(server)) throw new Error('bad server');
++ const keys = sshKeys.map((k) => k.trim()).filter(Boolean);
++ const script = [
++ '#!/bin/sh',
++ `# Made on ${server}/setup/sd-card: adds this robot to openvibe.bot once, on its first boot, with the card's setup key.`,
++ '# Its output: journalctl -u openvibe-setup',
++ 'key="$(cat /etc/openvibe/setup-key)"',
++ 'for i in $(seq 1 90); do',
++ ` curl -fsS -o /dev/null --max-time 10 ${server}/install && break`,
++ ' echo "waiting for the network ($i of 90)"; sleep 10',
++ 'done',
++ 'for try in 1 2 3; do',
++ ` if curl -fsSL ${server}/install | sh -s -- --setup "$key"; then rm -f /etc/openvibe/setup-key; exit 0; fi`,
++ ' echo "the installer stopped (try $try of 3): trying again in a minute"; sleep 60',
++ 'done',
++ 'exit 1',
++ ];
++ const lines = [
++ '#cloud-config',
++ `# Made on ${server}/setup/sd-card${robotName ? ` for ${String(robotName).replace(/[\r\n]/g, ' ')}` : ''}. Copy this file and network-config onto`,
++ '# the card\'s boot partition (bootfs), replacing the ones there. On first boot the Pi joins your Wi-Fi, turns on SSH',
++ '# and adds itself to openvibe.bot.',
++ `hostname: ${yamlQuote(hostname)}`,
++ 'manage_etc_hosts: true',
++ 'packages:',
++ '- avahi-daemon',
++ '',
++ 'apt:',
++ ' preserve_sources_list: true',
++ ' conf: |',
++ ' Acquire {',
++ ' Check-Date "false";',
++ ' };',
++ '',
++ ...(timezone ? [`timezone: ${yamlQuote(timezone)}`] : []),
++ 'user:',
++ ` name: ${yamlQuote(username)}`,
++ ' shell: /bin/bash',
++ ' lock_passwd: false',
++ ` passwd: ${yamlQuote(passwordHash)}`,
++ ...(keys.length ? [' ssh_authorized_keys:', ...keys.map((k) => ` - ${yamlQuote(k)}`)] : []),
++ ' sudo: ALL=(ALL) NOPASSWD:ALL',
++ '',
++ 'ssh_pwauth: true',
++ '',
++ 'rpi:',
++ ' interfaces:',
++ ' i2c: true',
++ ' spi: true',
++ '',
++ 'write_files:',
++ '- path: /etc/openvibe/setup-key',
++ ' permissions: "0600"',
++ ` content: ${yamlQuote(setupKey)}`,
++ '- path: /usr/local/sbin/openvibe-firstboot',
++ ' permissions: "0755"',
++ ' content: |',
++ ...script.map((l) => ` ${l}`),
++ '',
++ 'runcmd:',
++ ' - [ systemctl, enable, --now, ssh ]',
++ ' - [ systemd-run, --unit=openvibe-setup, "--description=Add this robot to openvibe.bot", /usr/local/sbin/openvibe-firstboot ]',
++ '',
++ ];
++ return lines.join('\n');
++ }
++
++ /** network-config: Wi-Fi for wlan0 (and Ethernet if a cable is in), in the form Imager writes. */
++ function networkConfig({ ssid, password = '', country, hidden = false }) {
++ const lines = [
++ '# Made on openvibe.bot/setup/sd-card: the Wi-Fi this Raspberry Pi joins on first boot.',
++ 'network:',
++ ' version: 2',
++ ' ethernets:',
++ ' eth0:',
++ ' dhcp4: true',
++ ' dhcp6: true',
++ ' optional: true',
++ ' wifis:',
++ ' wlan0:',
++ ' dhcp4: true',
++ ` regulatory-domain: ${yamlQuote(String(country).toUpperCase())}`,
++ ' access-points:',
++ ` ${yamlQuote(ssid)}:`,
++ ...(hidden ? [' hidden: true'] : []),
++ ...(password ? [` password: ${yamlQuote(password)}`] : [' auth:', ' key-management: none']),
++ ' optional: true',
++ '',
++ ];
++ return lines.join('\n');
++ }
++
++ // ── The page ─────────────────────────────────────────────────────────────────────────────────────────────
++ function init(doc) {
++ const form = doc.getElementById('sd-form');
++ if (!form) return;
++ const $ = (sel) => doc.querySelector(sel);
++ const out = $('#sd-files');
++ const err = $('#sd-error');
++ const btn = form.querySelector('button[type=submit]');
++ const tz = form.querySelector('input[name=timezone]');
++ try { if (tz && !tz.value) tz.value = Intl.DateTimeFormat().resolvedOptions().timeZone || ''; } catch { /* no zone: the Pi keeps UTC */ }
++ const country = form.querySelector('select[name=wifi_country]');
++ if (country && !country.dataset.chosen) {
++ const guess = ((navigator.language || '').split('-')[1] || '').toUpperCase();
++ if (guess && country.querySelector(`option[value="${guess}"]`)) country.value = guess;
++ }
++ // The hostname follows the name until it is typed in.
++ const name = form.querySelector('input[name=name]');
++ const host = form.querySelector('input[name=hostname]');
++ const slug = (s) => String(s).toLowerCase().normalize('NFKD').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'robot';
++ let hostTyped = !!(host && host.value);
++ if (host) host.addEventListener('input', () => { hostTyped = true; });
++ if (name && host) name.addEventListener('input', () => { if (!hostTyped) host.value = slug(name.value); });
++ if (name && host && !host.value) host.value = slug(name.value || 'robot');
++
++ const say = (msg) => { err.textContent = msg; err.hidden = !msg; if (msg) err.focus(); };
++ const download = (a, filename, text) => {
++ const url = URL.createObjectURL(new Blob([text], { type: 'application/octet-stream' }));
++ a.href = url;
++ a.download = filename;
++ };
++
++ form.addEventListener('submit', async (ev) => {
++ ev.preventDefault();
++ say('');
++ const f = new FormData(form);
++ const v = (k) => String(f.get(k) || '');
++ if (v('password').length < 8) return say('Choose a login password of at least 8 characters (you use it to SSH in).');
++ if (v('password') !== v('password2')) return say('The two login passwords are not the same.');
++ if (v('wifi_password') && (v('wifi_password').length < 8 || v('wifi_password').length > 63)) return say('A Wi-Fi password is 8 to 63 characters. Leave it empty only for an open network.');
++ if (!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(v('hostname'))) return say('The hostname is lowercase letters, digits and dashes.');
++ if (!/^[a-z_][a-z0-9_-]{0,31}$/.test(v('username')) || v('username') === 'root') return say('The username is lowercase letters, digits, - and _ (not root).');
++ btn.disabled = true;
++ const label = btn.textContent;
++ btn.textContent = 'Making your card files…';
++ try {
++ const r = await fetch('/setup/sd-card/keys', {
++ method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
++ // Only these leave the page: never the Wi-Fi or the login password.
++ body: JSON.stringify({ name: v('name'), profile_id: v('profile_id'), access_policy: v('access_policy'), live: f.get('live') === 'on', hostname: v('hostname') }),
++ });
++ const j = await r.json().catch(() => ({}));
++ if (!r.ok) throw new Error(j.detail || j.error || `openvibe.bot answered ${r.status}`);
++ const passwordHash = await sha512Crypt(v('password'), newSalt());
++ const files = {
++ 'user-data': userData({ hostname: v('hostname'), username: v('username'), passwordHash, sshKeys: v('ssh_keys').split(/\r?\n/), timezone: v('timezone'),
++ setupKey: j.key, server: form.dataset.server || location.origin, robotName: v('name') }),
++ 'network-config': networkConfig({ ssid: v('wifi_ssid'), password: v('wifi_password'), country: v('wifi_country'), hidden: f.get('wifi_hidden') === 'on' }),
++ };
++ download($('#sd-dl-user'), 'user-data', files['user-data']);
++ download($('#sd-dl-net'), 'network-config', files['network-config']);
++ for (const el of doc.querySelectorAll('[data-sd-host]')) el.textContent = v('hostname');
++ for (const el of doc.querySelectorAll('[data-sd-user]')) el.textContent = v('username');
++ const made = $('#sd-made');
++ if (made) {
++ made.textContent = `Card files made for ${v('name')}: it joins the Wi-Fi network "${v('wifi_ssid')}", and you can log in as ${v('username')}@${v('hostname')}.local.`;
++ made.hidden = false;
++ }
++ out.hidden = false;
++ form.hidden = true;
++ out.scrollIntoView({ behavior: 'smooth', block: 'start' });
++ watch(j.id);
++ } catch (e) {
++ say(`Could not make the card files: ${e.message}`);
++ } finally {
++ btn.disabled = false;
++ btn.textContent = label;
++ }
++ });
++
++ // After the files: follow the card. waiting → installing (the Pi asked with its key) → paired (online).
++ function watch(id) {
++ const status = $('#sd-status');
++ const text = status.querySelector('[data-sd-text]');
++ const open = status.querySelector('[data-sd-open]');
++ const words = {
++ waiting: 'Waiting for your robot to start. After you power it on, this takes 3 to 10 minutes.',
++ installing: 'Your robot asked to join with this card. It is installing OpenVibe and its driver…',
++ paired: 'Your robot is online.',
++ expired: 'These card files are more than 7 days old. Make new ones.',
++ revoked: 'These card files were revoked. Make new ones.',
++ };
++ const tick = async () => {
++ let s = null;
++ try { const r = await fetch(`/setup/sd-card/keys/${encodeURIComponent(id)}`, { credentials: 'same-origin', headers: { Accept: 'application/json' } }); if (r.ok) s = await r.json(); } catch { /* try again */ }
++ if (s) {
++ status.dataset.state = s.state;
++ text.textContent = words[s.state] || s.state;
++ if (s.robot_id) { open.href = s.state === 'paired' ? `/panel/${encodeURIComponent(s.robot_id)}` : `/r/${encodeURIComponent(s.robot_id)}`; open.hidden = false; open.textContent = s.state === 'paired' ? 'Open the panel' : 'See the robot'; }
++ if (s.state === 'paired' || s.state === 'expired' || s.state === 'revoked') return;
++ }
++ setTimeout(tick, 5000);
++ };
++ tick();
++ }
++ }
++
++ return { sha512Crypt, newSalt, yamlQuote, userData, networkConfig, init };
++}));
ddiff --git a/server/api/v1.js b/server/api/v1.js
iindex 21f8107..7d022b1 100644
---- a/server/api/v1.js
++++ b/server/api/v1.js
@@@ -561,7 +561,9 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
const needClaims = () => { if (!claims) fail(503, 'bot.unavailable', 'adding a device by code is not available here'); };
r.post('/claims', claimStart, wrap(async (req, res) => {
needClaims();
-- const out = await claims.start({ hostname: req.body?.hostname, hardware: req.body?.hardware, address: req.ip || null });
++ // setup_key: a card made on /setup/sd-card (server/setup-keys.js) approves its own claim.
++ const out = await claims.start({ hostname: req.body?.hostname, hardware: req.body?.hardware, address: req.ip || null,
++ setupKey: typeof req.body?.setup_key === 'string' ? req.body.setup_key : null });
res.setHeader('Cache-Control', 'no-store');
res.status(201).json(out);
}));
ddiff --git a/server/app.js b/server/app.js
iindex 0c5fa41..3a01ac9 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -38,6 +38,7 @@ const { createOnvif } = require('./onvif');
const { createApiAuth } = require('./api/auth');
const { v1Router } = require('./api/v1');
const { createClaims } = require('./claims');
++const { createSetupKeys } = require('./setup-keys');
const { createJobs } = require('./jobs');
const { createActorLimits } = require('./api/actor-limits');
const { createSessionRoutes, viewerMiddleware } = require('./web/session');
@@@ -89,7 +90,9 @@ function createApp(opts = {}) {
const submissions = createSubmissions({ db, config, now, log });
// Devices that ask to be added (the installer with no code, /link): the approval makes the robot; once its device
// has paired, what the owner asked for is turned on (server/claims.js).
-- const claims = createClaims({ db, domain, config, now, log });
++ // A card made on /setup/sd-card carries a setup key: its claim is approved at once (server/setup-keys.js).
++ const setupKeys = createSetupKeys({ db, now });
++ const claims = createClaims({ db, domain, config, setupKeys, now, log });
domain.pairing.onPaired((robotId) => claims.afterPair(robotId));
hub.bindDomain(domain);
// One jobs service for the device socket and the jobs API, so both see the same stdout rings.
@@@ -173,7 +176,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, submissions, routines, webhooks, claims, 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, routines, webhooks, claims, setupKeys, 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 });
@@@ -192,7 +195,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, offers, sim, onvif, userAuth, metrics, submissions, routines, webhooks, claims });
++ Object.assign(app.locals, { config, db, valkey, domain, keys, outbox, usage, hub, jobs, offers, sim, onvif, userAuth, metrics, submissions, routines, webhooks, claims, setupKeys });
return app;
}
ddiff --git a/server/claims.js b/server/claims.js
iindex e090803..5985566 100644
---- a/server/claims.js
++++ b/server/claims.js
@@@ -13,7 +13,9 @@
* paired (domain.pairing.onPaired) the claim drops the pairing and turns on what the owner asked for: the robot's
* stream exists only from then on.
*
-- * start({ hostname, hardware, address }) → { id, user_code, secret, expires_at, interval, verification_url, … }
++ * start({ hostname, hardware, address, setupKey }) → { id, user_code, secret, expires_at, interval, verification_url, … }
++ * with a setup key (a card made on /setup/sd-card, server/setup-keys.js) the claim is approved at once for the
++ * key's owner with the choices made with the card files; a retry before the robot has paired pairs the same robot
* poll(id, secret) → { state: pending | approved (+ pairing) | denied | expired | paired }
* find(userCode) → the pending claim the code names, or null
* approve(id, owner, { name, profile_id, access_policy, live }) → { robot, claim }
@@@ -54,10 +56,18 @@ function cleanHardware(h) {
return out;
}
--function createClaims({ db, domain, config, now = () => Date.now(), log = console }) {
++function createClaims({ db, domain, config, setupKeys = null, now = () => Date.now(), log = console }) {
const base = String(config.baseUrl || 'https://openvibe.bot').replace(/\/+$/, '');
-- async function start({ hostname, hardware, address = null } = {}) {
++ async function start({ hostname, hardware, address = null, setupKey = null } = {}) {
++ let key = null;
++ if (setupKey != null && setupKey !== '') {
++ key = setupKeys ? await setupKeys.redeem(setupKey) : null;
++ if (!key) {
++ fail(403, 'bot.setup_key_invalid', "this card's setup key was used, revoked or is more than 7 days old: make new card files "
++ + `on ${base}/setup/sd-card, or run the installer without --setup and approve its code on ${base}/link`);
++ }
++ }
const hw = cleanHardware(hardware);
const kit = hw.kit && !hw.kit.startsWith('local.') ? await getProfile(db, hw.kit).catch(() => null) : null;
const id = prefixedId('clm', now());
@@@ -73,9 +83,11 @@ function createClaims({ db, domain, config, now = () => Date.now(), log = consol
address ? clip(String(address), 64) : null, iso(at), iso(at + TTL_MS)]);
if (row) {
const userCode = formatCode(code);
++ if (key) await approveWithKey(id, key);
return {
id, user_code: userCode, secret, expires_at: iso(at + TTL_MS), interval: POLL_S,
verification_url: `${base}/link`, verification_url_complete: `${base}/link?code=${userCode}`,
++ ...(key ? { approved: true } : {}),
};
}
}
@@@ -139,6 +151,30 @@ function createClaims({ db, domain, config, now = () => Date.now(), log = consol
return { robot: made.robot, claim };
}
++ /**
++ * A claim started with a setup key: approved for the key's owner at once. The first one makes the robot with the
++ * card's choices; a retry (the robot has not paired yet) gives the same robot a fresh pairing code instead of a
++ * second robot. Two first starts at once: the one that marks the key first makes the robot, the other is refused.
++ */
++ async function approveWithKey(id, key) {
++ const existing = key.robot_id ? await domain.robots.get(key.robot_id) : null;
++ if (existing) {
++ const p = await domain.pairing.create(existing.id, key.owner_subject, null, existing);
++ await db.query(
++ `UPDATE device_claims SET state = 'approved', owner_subject = $2, robot_id = $3, pairing = $4::jsonb, want_live = $5, decided_at = $6
++ WHERE id = $1 AND state = 'pending'`,
++ [id, key.owner_subject, existing.id, JSON.stringify({ code: p.code, expires_at: p.expires_at, pairing_id: p.pairing_id || null }),
++ !!key.want_live, iso(now())]);
++ await setupKeys.attach(key.id, { claim_id: id });
++ return;
++ }
++ const first = await db.maybe('UPDATE device_setup_keys SET claimed_at = $2, claim_id = $3 WHERE id = $1 AND claimed_at IS NULL RETURNING id',
++ [key.id, iso(now()), id]);
++ if (!first) fail(409, 'bot.setup_key_busy', 'this card is being added right now: wait a minute and run the installer again');
++ const made = await approve(id, key.owner_subject, { name: key.name, profile_id: key.profile_id, access_policy: key.access_policy, live: key.want_live });
++ await setupKeys.attach(key.id, { claim_id: id, robot_id: made.robot.id });
++ }
++
async function deny(id, owner) {
await pending(id);
const row = await db.maybe("UPDATE device_claims SET state = 'denied', owner_subject = $2, decided_at = $3 WHERE id = $1 AND state = 'pending' RETURNING id", [id, owner, iso(now())]);
@@@ -147,6 +183,7 @@ function createClaims({ db, domain, config, now = () => Date.now(), log = consol
/** The robot paired: drop the pairing, and turn on what its owner asked for when they approved it. */
async function afterPair(robotId) {
++ if (setupKeys) await setupKeys.paired(robotId).catch((e) => log.warn(`[Bot] setup key of ${robotId}: ${e.message}`));
const row = await db.maybe(
`UPDATE device_claims SET state = 'paired', pairing = NULL, paired_at = $2
WHERE robot_id = $1 AND state IN ('approved', 'paired') RETURNING *`, [robotId, iso(now())]);
ddiff --git a/server/index.js b/server/index.js
iindex c0e911e..b821307 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -34,7 +34,7 @@ async function main() {
const valkey = createValkey({ url: config.valkey.url, prefix: config.valkey.prefix });
const app = createApp({ config, db, valkey, registry });
-- const { domain, keys, outbox, usage, hub, offers, sim, onvif, routines, webhooks, claims } = app.locals;
++ const { domain, keys, outbox, usage, hub, offers, sim, onvif, routines, webhooks, claims, setupKeys } = app.locals;
keys.start().catch(() => { /* logged by the JWKS client */ });
const timers = [];
@@@ -43,6 +43,7 @@ async function main() {
every(6 * 3600 * 1000, () => domain.audit.prune(30));
every(30 * 60 * 1000, () => domain.pairing.prune());
every(30 * 60 * 1000, () => claims.prune()); // device claims a day past their time (server/claims.js)
++ every(6 * 3600 * 1000, () => setupKeys.prune()); // card setup keys a month past their time (server/setup-keys.js)
every(1000, async () => { for (const robotId of await domain.queue.sweep()) hub.broadcast(robotId); });
outbox.start();
usage.start();
ddiff --git a/server/setup-keys.js b/server/setup-keys.js
nnew file mode 100644
iindex 0000000..9686284
---- /dev/null
++++ b/server/setup-keys.js
@@@ -0,0 +1,89 @@
++'use strict';
++/**
++ * Setup keys: a microSD card that adds its robot to openvibe.bot by itself (owner 2026-10-24: "if you just have a fresh
++ * MicroSD card … the site needs to guide the user … now I can't even enter that command").
++ *
++ * /setup/sd-card makes the card's first-boot files in the person's browser: Wi-Fi, a login, SSH, and one command that
++ * runs the installer with a setup key (`install.sh --setup <key>`). The installer starts a claim with the key
++ * (server/claims.js), and the claim is approved at once for the key's owner with the choices made with the card files
++ * (name, model, who may drive, Live) — no code to read off a screen, no /link.
++ *
++ * create(owner, { name, profile_id, access_policy, live, hostname }) → { id, key, expires_at } (the key only here)
++ * redeem(key) → the live key row (not revoked, expired or spent), or null
++ * attach(id, { claim_id, robot_id }) the claim it started (and the robot, once)
++ * paired(robotId) → the robot paired: its key is spent
++ * status(owner, id) → { state: waiting | installing | paired | expired | revoked, robot_id, … } or null
++ * revoke(owner, id)
++ *
++ * A key lasts until its robot has paired, or 7 days. Until then a retry (the installer failed half way, the card was
++ * flashed again with the same files) pairs the same robot with a fresh code rather than making a second one. The key is
++ * a bearer secret on the card: it can only add a robot to its owner's account, once. Only its hash is kept.
++ */
++const { prefixedId, iso, token, hashSecret, fail, text } = require('./util');
++
++const TTL_MS = 7 * 24 * 3600 * 1000;
++const KEY_RE = /^osk_[A-Za-z0-9_-]{43}$/;
++const POLICIES = ['private', 'invite', 'queue'];
++// What a hostname may be (RFC 1123 label): the card writes it and the person ssh-es to <hostname>.local.
++const HOSTNAME_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
++
++function createSetupKeys({ db, now = () => Date.now() }) {
++ async function create(owner, { name, profile_id, access_policy = 'private', live = false, hostname = null } = {}) {
++ if (!owner) fail(401, 'bot.unauthenticated', 'sign in to make card files');
++ const cleanName = text(name, 'name', 80);
++ if (!cleanName) fail(422, 'bot.invalid_input', 'name is required');
++ if (!profile_id || typeof profile_id !== 'string') fail(422, 'bot.unknown_profile', 'choose a model');
++ if (!POLICIES.includes(access_policy)) fail(422, 'bot.invalid_policy', 'access_policy must be private, invite or queue');
++ const host = hostname == null || hostname === '' ? null : String(hostname).toLowerCase();
++ if (host !== null && !HOSTNAME_RE.test(host)) fail(422, 'bot.invalid_input', 'the hostname is letters, digits and dashes (at most 63)');
++ const id = prefixedId('osk', now());
++ const key = `osk_${token(32)}`;
++ const at = now();
++ await db.query(
++ `INSERT INTO device_setup_keys (id, owner_subject, secret_hash, name, profile_id, access_policy, want_live, hostname, created_at, expires_at)
++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
++ [id, owner, hashSecret(key), cleanName, profile_id, access_policy, !!live, host, iso(at), iso(at + TTL_MS)]);
++ return { id, key, expires_at: iso(at + TTL_MS) };
++ }
++
++ async function redeem(key) {
++ if (typeof key !== 'string' || !KEY_RE.test(key)) return null;
++ const row = await db.maybe('SELECT * FROM device_setup_keys WHERE secret_hash = $1', [hashSecret(key)]);
++ if (!row || row.revoked_at || row.paired_at || new Date(row.expires_at).getTime() <= now()) return null;
++ return row;
++ }
++
++ async function attach(id, { claim_id, robot_id = null }) {
++ await db.query(
++ `UPDATE device_setup_keys SET claim_id = $2, robot_id = COALESCE(robot_id, $3), claimed_at = COALESCE(claimed_at, $4) WHERE id = $1`,
++ [id, claim_id, robot_id, iso(now())]);
++ }
++
++ async function paired(robotId) {
++ await db.query('UPDATE device_setup_keys SET paired_at = $2 WHERE robot_id = $1 AND paired_at IS NULL', [robotId, iso(now())]);
++ }
++
++ async function status(owner, id) {
++ const row = typeof id === 'string' && /^osk_[0-9A-Z]{26}$/.test(id)
++ ? await db.maybe('SELECT * FROM device_setup_keys WHERE id = $1 AND owner_subject = $2', [id, owner]) : null;
++ if (!row) return null;
++ const state = row.revoked_at ? 'revoked' : row.paired_at ? 'paired' : row.robot_id ? 'installing'
++ : new Date(row.expires_at).getTime() <= now() ? 'expired' : 'waiting';
++ return { id: row.id, state, name: row.name, hostname: row.hostname, robot_id: row.robot_id || null,
++ expires_at: new Date(row.expires_at).toISOString(), claimed_at: row.claimed_at ? new Date(row.claimed_at).toISOString() : null };
++ }
++
++ async function revoke(owner, id) {
++ const row = await db.maybe('UPDATE device_setup_keys SET revoked_at = $3 WHERE id = $1 AND owner_subject = $2 AND revoked_at IS NULL AND paired_at IS NULL RETURNING id',
++ [id, owner, iso(now())]);
++ if (!row) fail(404, 'bot.not_found', 'no such card files, or they were already used or revoked');
++ }
++
++ async function prune() {
++ await db.query('DELETE FROM device_setup_keys WHERE expires_at < $1', [iso(now() - 30 * 24 * 3600 * 1000)]);
++ }
++
++ return { create, redeem, attach, paired, status, revoke, prune, TTL_MS };
++}
++
++module.exports = { createSetupKeys, SETUP_KEY_RE: KEY_RE, SETUP_KEY_TTL_MS: TTL_MS, HOSTNAME_RE };
ddiff --git a/server/web/directory.js b/server/web/directory.js
iindex 66ac347..6e3b487 100644
---- a/server/web/directory.js
++++ b/server/web/directory.js
@@@ -191,7 +191,7 @@ function renderModel({ config, model, kit = null }) {
${kit.build_guide_url ? `<p>${link(kit.build_guide_url, 'The kit\'s build guide')}</p>` : ''}</section>` : '';
const add = model.connect === 'browser'
? `${link('/try', 'Try it now, no account', 'dir-btn dir-primary')} ${link(`/robots/new?profile=${encodeURIComponent(model.id)}`, 'Add one to your robots', 'dir-btn')}`
-- : `${link(`/robots/new?profile=${encodeURIComponent(model.id)}`, 'Add this robot', 'dir-btn dir-primary')}${model.guide ? ` ${link(model.guide.href, model.guide.label, 'dir-btn')}` : ''}`;
++ : `${link(`/robots/new?profile=${encodeURIComponent(model.id)}`, 'Add this robot', 'dir-btn dir-primary')}${model.connect === 'onboard' ? ` ${link(`/setup/sd-card?profile=${encodeURIComponent(model.id)}`, 'Start from a blank microSD card', 'dir-btn')}` : ''}${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)}${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>
@@@ -235,6 +235,8 @@ function renderAddRobot({ config, models = [], values = {}, error = null }) {
.map(([k, title]) => [title, sortModels(models).filter((m) => m.connect === k)]).filter(([, ms]) => ms.length);
const body = `<p class="dir-crumb">${link('/robots', 'Robots')}</p>
<header class="dir-hero"><h1>Add a robot</h1><p class="muted">Name it, say what it is, and choose who may drive. A simulated rover drives the moment it is added; anything else gets a one-time pairing code and one command to paste on its computer.</p></header>
++<section class="dir-form sd-entry" aria-labelledby="add-from-card"><h2 id="add-from-card" class="dir-field-label">Starting from a blank microSD card?</h2>
++<p class="muted">${link(`/setup/sd-card${values.profile_id ? `?profile=${encodeURIComponent(values.profile_id)}` : ''}`, 'Make the card\'s setup files')}: write Raspberry Pi OS with Raspberry Pi Imager, add two files, switch the robot on. It joins your Wi-Fi and adds itself, with no screen, keyboard or command.</p></section>
<section class="dir-form" aria-labelledby="add-from-device"><h2 id="add-from-device" class="dir-field-label">At the robot already? Start there</h2>
<p class="muted">On its Raspberry Pi (or other Linux computer) run <code>curl -fsSL ${esc(config.installer.scriptUrl)} | sh</code>. It finds the kit (an Adeept ADR036 4WD and the other models below), turns on what it needs, and shows a code: enter it at ${link('/link', 'openvibe.bot/link')} and approve it. Nothing else to fill in here.</p></section>
<form class="dir-form" method="post" action="/robots">
ddiff --git a/server/web/home.js b/server/web/home.js
iindex ccd953a..52c4e63 100644
---- a/server/web/home.js
++++ b/server/web/home.js
@@@ -73,7 +73,7 @@ function hero({ signedIn, config }) {
<p class="hm-term-top"><span></span><span></span><span></span><b>On the robot's computer</b></p>
<pre tabindex="0"><code><span class="hm-prompt">$</span> ${esc(install)}</code></pre>
<ol class="hm-term-steps"><li>It finds the kit and shows a short code.</li><li>Approve the code at ${link('/link', 'openvibe.bot/link')}.</li><li>Open the robot's panel and drive.</li></ol>
--<p class="hm-term-foot">Raspberry Pi OS or any Linux. No robot yet? ${link('/try', 'Drive the simulated rover')}.</p></div>`;
++<p class="hm-term-foot">Raspberry Pi OS or any Linux. A blank microSD card? ${link('/setup/sd-card', 'Make its setup files')}, no screen needed. No robot yet? ${link('/try', 'Drive the simulated rover')}.</p></div>`;
return showcase.hero({
eyebrow: 'OpenVibe.Bot · alpha',
title: 'Drive your robot', accent: 'from any browser.',
ddiff --git a/server/web/render.js b/server/web/render.js
iindex 0dd81c4..c795e3f 100644
---- a/server/web/render.js
++++ b/server/web/render.js
@@@ -600,6 +600,7 @@ ${error ? `<p class="error" role="alert">${esc(error)}</p>` : ''}
<label class="field"><span class="field-label">Code</span><input type="text" name="code" autocomplete="off" autocapitalize="characters" spellcheck="false" maxlength="12" placeholder="XXXX-XXXX" value="${esc(code)}" required autofocus></label>
<button type="submit" class="primary">Continue</button>
</form>
++<p class="muted">Nothing on the robot's card yet? <a href="/setup/sd-card">Start from a blank microSD card</a>: the card adds the robot by itself, with no code.</p>
</main>`;
return page('Add a device', body);
}
ddiff --git a/server/web/routes.js b/server/web/routes.js
iindex 70594dd..97360c1 100644
---- a/server/web/routes.js
++++ b/server/web/routes.js
@@@ -39,6 +39,7 @@ const kits = require('../kits');
const { renderPanel, renderEmbedRefused, renderDrivePage, renderPairingPage, renderPhonePage, renderMachinesPage, renderMachineScreenPage, renderLinkPage, renderClaimPage, renderClaimApproved } = require('./render');
const { createMachineScreen } = require('../machine-screen');
const { renderDocsIndex, renderDriversPage, renderProfilesPage, renderAutomationPage, renderComputersPage } = require('./docs');
++const { renderSdCard } = require('./sd-card');
const { checkProfile } = require('../profiles');
const { renderHome, renderUpdates, HOME_CSP } = require('./home');
const { renderDirectory, renderModels, renderModel, renderRobotPage, renderAddRobot, renderSubmit, renderSubmissions, modelOf, sortModels } = require('./directory');
@@@ -73,6 +74,11 @@ const LLMS_PAGES = [
note: 'Drive a simulated rover in the browser, no account and no hardware.',
text: 'A rover simulated in the visitor\'s own browser, driven through the same OpenVibe.Bot panel a paired robot gets: a touch joystick, the keyboard (W A S D or the arrows, Space to stop) or a gamepad, with the same command shaping, deadman and latched e-stop. It has a front camera drawn from its position in a small arena, an ultrasonic distance sensor, three line sensors over a strip of yellow tape, a battery, RGB headlights and a horn. Nothing pressed there moves real hardware, and nothing needs an account. The page ends with the way to a real robot: add one, pair its computer with one pasted command, and the same panel drives it.',
},
++ {
++ path: '/setup/sd-card', title: 'A robot from a blank microSD card',
++ note: 'Set up a Raspberry Pi robot from a blank card with no screen or keyboard: the card adds the robot by itself.',
++ text: 'A guided setup for a Raspberry Pi robot starting from a blank microSD card. Signed in, the person describes the robot (name, model, who may drive, its camera on their OpenVibe.Live channel) and their Wi-Fi, and chooses a login; the page makes the card\'s two cloud-init files, user-data and network-config, in the browser, so the Wi-Fi and login passwords never reach openvibe.bot (the login password is stored hashed with SHA-512 crypt). They write Raspberry Pi OS Lite (64-bit) to the card with Raspberry Pi Imager without its customisation, copy the two files to the card\'s bootfs drive and switch the robot on. On first boot the Pi joins the Wi-Fi, turns on SSH and I2C, and runs the OpenVibe installer with the card\'s setup key, which adds the robot to the person\'s account approved, with no code to read off a screen. The page follows the card until the robot is online. A setup key adds one robot and lasts 7 days; a retry before the robot pairs pairs the same robot again. If it does not arrive, the person can SSH to <hostname>.local with the login they chose and read journalctl -u openvibe-setup.',
++ },
{
path: '/robots', title: 'Robots',
note: 'The robot directory: robots people list, watchable live, and the models Bot drives.',
@@@ -142,12 +148,12 @@ 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, submissions = null, routines = null, webhooks = null, claims = null, releaseLastmod } = {}) {
++function createWebRoutes(config, { domain = null, sim = null, onvif = null, limits = null, identity = null, hub = null, log = console, submissions = null, routines = null, webhooks = null, claims = null, setupKeys = null, releaseLastmod } = {}) {
const r = express.Router();
const networkReads = createNetworkReads({ searchUrl: config.search && config.search.url, log });
// 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: /robots/submit', 'Disallow: /robots/submissions', 'Disallow: /pair/', 'Disallow: /panel/', 'Disallow: /install', 'Disallow: /link', `Sitemap: ${config.baseUrl}/sitemap.xml`, ''].join('\n')));
++ 'Disallow: /robots/new', 'Disallow: /robots/submit', 'Disallow: /robots/submissions', 'Disallow: /pair/', 'Disallow: /setup/sd-card/keys', 'Disallow: /panel/', 'Disallow: /install', 'Disallow: /link', `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.
@@@ -194,6 +200,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
r.get('/panel/sim-view.js', asset('sim-view.js', 'application/javascript'));
r.get('/panel/try-device.js', asset('try-device.js', 'application/javascript'));
r.get('/panel/phone-device.js', asset('phone-device.js', 'application/javascript'));
++ r.get('/panel/sd-card.js', asset('sd-card.js', 'application/javascript'));
// /device/phone: a phone becomes the robot's device from its browser (plan T15). Public: the one-time pairing code
// is the credential, and a paired phone keeps its device credential in its own storage. Its CSP adds only the
@@@ -505,6 +512,51 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
return res.type('html').send(renderLinkPage({ installer: linkInstaller(), error: 'Denied. That device was told no and added nothing.' }));
}));
++ // ── A robot from a blank microSD card (server/setup-keys.js, server/web/sd-card.js, public/sd-card.js) ──
++ // The page is public (the steps), the form needs a sign-in. The browser asks for a setup key with the robot's
++ // choices only (never the Wi-Fi or login password, which go only into the files it makes) and follows the card.
++ const sdJson = (fn) => async (req, res) => {
++ res.setHeader('Cache-Control', 'no-store');
++ try { await fn(req, res); } catch (e) {
++ const status = e.status && e.status < 600 ? e.status : 500;
++ if (status === 500) log.warn(`[Bot] setup key: ${e.message}`);
++ res.status(status).json({ error: e.code || 'bot.error', detail: status === 500 ? 'something went wrong; try again' : (e.detail || e.code) });
++ }
++ };
++ const needSetupKeys = () => { if (!setupKeys || !claims) throw new BotError(404, 'bot.not_found', 'card setup is not available here'); };
++ r.get('/setup/sd-card', page(async (req, res) => {
++ needSetupKeys();
++ res.setHeader('Content-Security-Policy', HOME_CSP);
++ const models = await claimModels();
++ const asked = typeof req.query.profile === 'string' && models.some((m) => m.id === req.query.profile) ? req.query.profile : null;
++ res.type('html').send(renderSdCard({ config, signedIn: !!req.viewer, models, values: { profile_id: asked } }));
++ }));
++ r.post('/setup/sd-card/keys', ...manage, express.json({ limit: '4kb' }), sdJson(async (req, res) => {
++ needSetupKeys();
++ const me = requireUser(req);
++ if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site requests are refused');
++ const b = req.body || {};
++ const models = await claimModels();
++ if (!models.some((m) => m.id === b.profile_id)) throw new BotError(422, 'bot.unknown_profile', 'choose one of the models listed');
++ const out = await setupKeys.create(me.subject, { name: b.name, profile_id: b.profile_id, access_policy: String(b.access_policy || 'private'),
++ live: b.live === true, hostname: typeof b.hostname === 'string' ? b.hostname : null });
++ res.status(201).json(out);
++ }));
++ r.get('/setup/sd-card/keys/:id', ...manage, sdJson(async (req, res) => {
++ needSetupKeys();
++ const me = requireUser(req);
++ const s = await setupKeys.status(me.subject, req.params.id);
++ if (!s) throw new BotError(404, 'bot.not_found', 'no such card files');
++ res.json(s);
++ }));
++ r.post('/setup/sd-card/keys/:id/revoke', ...manage, sdJson(async (req, res) => {
++ needSetupKeys();
++ const me = requireUser(req);
++ if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site requests are refused');
++ await setupKeys.revoke(me.subject, req.params.id);
++ res.json({ revoked: true });
++ }));
++
// A load replaces the robot's unused code, so only a navigation from this site (or a typed address) mints one.
const crossSiteOrPrefetch = (req) => {
const site = req.headers['sec-fetch-site'];
ddiff --git a/server/web/sd-card.js b/server/web/sd-card.js
nnew file mode 100644
iindex 0000000..43044e8
---- /dev/null
++++ b/server/web/sd-card.js
@@@ -0,0 +1,102 @@
++'use strict';
++/**
++ * GET /setup/sd-card: a robot from a blank microSD card (owner 2026-10-24: "if you just have a fresh MicroSD card how to
++ * flash … and be able to connect to the device headlessly … the site needs to guide the user").
++ *
++ * One page, four steps: describe the robot and the Wi-Fi (the form), write Raspberry Pi OS Lite with Raspberry Pi
++ * Imager, copy the two files this page makes onto the card, switch the robot on and watch it arrive. public/sd-card.js
++ * makes the files in the browser (the Wi-Fi and login passwords never leave it) with a setup key from
++ * POST /setup/sd-card/keys (server/setup-keys.js); the card's first boot runs the installer with that key and the robot
++ * adds itself, approved, with no code on a screen.
++ */
++const { esc } = require('./render');
++const { framePage } = require('./home');
++
++const POLICIES = [['private', 'Only me and people I add'], ['invite', 'People I invite'], ['queue', 'Anyone signed in, taking turns']];
++// Wi-Fi countries (the regulatory domain the Pi's radio must use): the common ones first, then the rest of the list.
++const COUNTRIES = [['US', 'United States'], ['CA', 'Canada'], ['GB', 'United Kingdom'], ['AU', 'Australia'], ['NZ', 'New Zealand'], ['IE', 'Ireland'],
++ ['DE', 'Germany'], ['FR', 'France'], ['ES', 'Spain'], ['IT', 'Italy'], ['NL', 'Netherlands'], ['BE', 'Belgium'], ['CH', 'Switzerland'], ['AT', 'Austria'],
++ ['SE', 'Sweden'], ['NO', 'Norway'], ['DK', 'Denmark'], ['FI', 'Finland'], ['PL', 'Poland'], ['PT', 'Portugal'], ['GR', 'Greece'], ['CZ', 'Czechia'],
++ ['BR', 'Brazil'], ['MX', 'Mexico'], ['AR', 'Argentina'], ['CL', 'Chile'], ['CO', 'Colombia'], ['JP', 'Japan'], ['KR', 'South Korea'], ['CN', 'China'],
++ ['TW', 'Taiwan'], ['HK', 'Hong Kong'], ['SG', 'Singapore'], ['IN', 'India'], ['PH', 'Philippines'], ['ID', 'Indonesia'], ['MY', 'Malaysia'], ['TH', 'Thailand'],
++ ['VN', 'Vietnam'], ['ZA', 'South Africa'], ['NG', 'Nigeria'], ['KE', 'Kenya'], ['EG', 'Egypt'], ['IL', 'Israel'], ['TR', 'Turkey'], ['AE', 'United Arab Emirates'],
++ ['SA', 'Saudi Arabia'], ['UA', 'Ukraine'], ['RO', 'Romania'], ['HU', 'Hungary']];
++
++const link = (href, text) => `<a href="${esc(href)}"${/^https?:/.test(href) ? ' rel="noopener"' : ''}>${esc(text)}</a>`;
++
++function renderSdCard({ config, signedIn = false, models = [], values = {} }) {
++ const chosen = models.some((m) => m.id === values.profile_id) ? values.profile_id : (models.find((m) => /adeept/.test(m.id)) || models[0] || {}).id;
++ const installer = `curl -fsSL ${config.installer.scriptUrl} | sh`;
++ const form = signedIn ? `<form id="sd-form" class="dir-form sd-form" data-server="${esc(config.baseUrl)}" novalidate>
++<p class="dir-error" id="sd-error" role="alert" tabindex="-1" hidden></p>
++<fieldset class="dir-fieldset"><legend class="sd-legend">The robot</legend>
++<label class="dir-field"><span class="dir-field-label">Name</span><input type="text" name="name" maxlength="80" required autocomplete="off" placeholder="e.g. Mecanum rover" value="${esc(values.name || '')}"></label>
++<label class="dir-field"><span class="dir-field-label">Model</span><select name="profile_id">${models.map((m) => `<option value="${esc(m.id)}"${m.id === chosen ? ' selected' : ''}>${esc(m.name)}</option>`).join('')}</select></label>
++<label class="dir-field"><span class="dir-field-label">Who may drive</span><select name="access_policy">${POLICIES.map(([v, l]) => `<option value="${esc(v)}">${esc(l)}</option>`).join('')}</select></label>
++<label class="sd-check"><input type="checkbox" name="live" value="on" checked> <span>Show its camera on my OpenVibe.Live channel once it is online</span></label>
++</fieldset>
++<fieldset class="dir-fieldset"><legend class="sd-legend">Your Wi-Fi</legend>
++<label class="dir-field"><span class="dir-field-label">Network name</span><input type="text" name="wifi_ssid" maxlength="32" required autocomplete="off" spellcheck="false"></label>
++<label class="dir-field"><span class="dir-field-label">Password</span><input type="password" name="wifi_password" maxlength="63" autocomplete="off"></label>
++<label class="dir-field"><span class="dir-field-label">Country</span><select name="wifi_country">${COUNTRIES.map(([c, n]) => `<option value="${c}">${esc(n)}</option>`).join('')}</select></label>
++<label class="sd-check"><input type="checkbox" name="wifi_hidden" value="on"> <span>The network is hidden (it does not show in Wi-Fi lists)</span></label>
++</fieldset>
++<fieldset class="dir-fieldset"><legend class="sd-legend">A login on the robot (for SSH, or a keyboard and screen)</legend>
++<label class="dir-field"><span class="dir-field-label">Username</span><input type="text" name="username" maxlength="32" required autocomplete="off" spellcheck="false" autocapitalize="none" value="robot"></label>
++<label class="dir-field"><span class="dir-field-label">Password</span><input type="password" name="password" minlength="8" required autocomplete="new-password"></label>
++<label class="dir-field"><span class="dir-field-label">Password again</span><input type="password" name="password2" minlength="8" required autocomplete="new-password"></label>
++<details class="sd-more"><summary>More: the hostname, an SSH key</summary>
++<label class="dir-field"><span class="dir-field-label">Hostname</span><input type="text" name="hostname" maxlength="63" autocomplete="off" spellcheck="false" autocapitalize="none" pattern="[a-z0-9-]+"></label>
++<p class="muted dir-note">You reach the robot at <code>&lt;hostname&gt;.local</code> on your network. Made from the name unless you change it.</p>
++<label class="dir-field dir-wide"><span class="dir-field-label">SSH public keys (optional, one per line)</span><textarea name="ssh_keys" rows="3" spellcheck="false" placeholder="ssh-ed25519 AAAA… you@laptop"></textarea></label>
++</details>
++<input type="hidden" name="timezone" value="">
++</fieldset>
++<p class="sd-private">Your Wi-Fi password and the login password stay in this browser. They go only into the files you download, never to openvibe.bot (the password is stored hashed, as Raspberry Pi Imager does).</p>
++<p><button type="submit" class="dir-btn dir-primary">Make my card files</button></p>
++</form>`
++ : `<p class="sd-signin">${link('/auth/login?next=%2Fsetup%2Fsd-card', 'Sign in')} to make your card's files: they add the robot to your account.</p>`;
++
++ const body = `<p class="dir-crumb">${link('/robots', 'Robots')} · ${link('/robots/new', 'Add a robot')}</p>
++<header class="dir-hero"><h1>A robot from a blank microSD card</h1>
++<p class="muted">No screen, no keyboard, nothing to type on the robot. This page makes the card's setup files: on its first boot your Raspberry Pi joins your Wi-Fi, turns on SSH, installs OpenVibe and adds itself to your robots.</p></header>
++<section class="dir-sec sd-need" aria-labelledby="sd-need-h"><h2 id="sd-need-h">What you need</h2>
++<ul class="sd-list"><li>The robot's Raspberry Pi (3B+, 4, 5 or Zero 2 W)</li><li>A microSD card, 16 GB or more: everything on it is erased</li><li>A computer with a card reader</li><li>Your Wi-Fi name and password (2.4 GHz works on every Pi)</li></ul></section>
++<ol class="sd-steps">
++<li class="sd-step"><h2>Describe the robot and your Wi-Fi</h2>
++${form}
++<p class="sd-made" id="sd-made" role="status" hidden></p></li>
++<li class="sd-step"><h2>Write Raspberry Pi OS to the card</h2>
++<ol class="sd-sub">
++<li>Install ${link('https://www.raspberrypi.com/software/', 'Raspberry Pi Imager')} on your computer (version 2.0 or newer) and put the card in.</li>
++<li><b>Device</b>: your Pi (e.g. Raspberry Pi 4). <b>Operating system</b>: Raspberry Pi OS (other) → <b>Raspberry Pi OS Lite (64-bit)</b>. <b>Storage</b>: your microSD card.</li>
++<li>When Imager offers to customise the OS, <b>skip it</b> (choose No): your two files do that.</li>
++<li>Write, and wait until Imager says it is done and verified.</li></ol></li>
++<li class="sd-step"><h2>Add your two files to the card</h2>
++<div id="sd-files" hidden>
++<p class="sd-dl"><a class="dir-btn dir-primary" id="sd-dl-user" href="#">Download user-data</a> <a class="dir-btn dir-primary" id="sd-dl-net" href="#">Download network-config</a></p>
++<p class="muted dir-note">Made a mistake? Reload this page and make new files. Files that are never used stop working after 7 days.</p>
++</div>
++<ol class="sd-sub">
++<li>Take the card out of the computer and put it back in. A drive called <b>bootfs</b> opens.</li>
++<li>Copy <code>user-data</code> and <code>network-config</code> there, replacing the files with the same names.</li>
++<li>The names must be exactly <code>user-data</code> and <code>network-config</code>, with no <code>.txt</code> (on Windows, turn on File name extensions to check).</li>
++<li>Eject the card.</li></ol></li>
++<li class="sd-step"><h2>Switch the robot on</h2>
++<p>Put the card in the robot's Pi and switch it on. The first start takes 3 to 10 minutes: it sets itself up, restarts once, joins your Wi-Fi and installs OpenVibe and the robot's driver.</p>
++<p class="sd-status" id="sd-status" data-state="idle" role="status" aria-live="polite"><span class="sd-dot"></span><span data-sd-text>${signedIn ? 'Make your card files first: this shows your robot arriving.' : 'Sign in and make your card files: this shows your robot arriving.'}</span> <a class="dir-btn" data-sd-open hidden href="#">Open the panel</a></p></li>
++</ol>
++<section class="dir-sec sd-help" aria-labelledby="sd-help-h"><h2 id="sd-help-h">If it does not arrive</h2>
++<ul class="sd-list">
++<li><b>Check the Wi-Fi name and password.</b> Both are exact (capitals matter). Some older Pis see only 2.4 GHz networks.</li>
++<li><b>Log in and look.</b> From a computer on the same Wi-Fi: <code>ssh <span data-sd-user>robot</span>@<span data-sd-host>your-hostname</span>.local</code> with the login you chose, then <code>journalctl -u openvibe-setup</code> shows what it did. With a screen and keyboard on the Pi, log in there.</li>
++<li><b>Each set of files adds one robot</b> and works for 7 days. To start again, make new files here.</li>
++<li><b>Raspberry Pi OS already running on it?</b> Skip the card: run <code>${esc(installer)}</code> on it and approve its code at ${link('/link', 'openvibe.bot/link')}.</li>
++</ul></section>
++<script src="/panel/sd-card.js" defer></script>`;
++ return framePage({ config, title: 'A robot from a blank microSD card · OpenVibe.Bot', canonicalPath: '/setup/sd-card',
++ description: 'Set up a Raspberry Pi robot from a blank microSD card with no screen or keyboard: the card joins your Wi-Fi and adds the robot to openvibe.bot by itself.',
++ head: '<link rel="stylesheet" href="/directory.css">', body: `<main id="main" class="dir sd">${body}</main>` });
++}
++
++module.exports = { renderSdCard, COUNTRIES };
ddiff --git a/test/setup-keys.test.js b/test/setup-keys.test.js
nnew file mode 100644
iindex 0000000..e8eb98e
---- /dev/null
++++ b/test/setup-keys.test.js
@@@ -0,0 +1,133 @@
++'use strict';
++// A robot from a blank microSD card (owner 2026-10-24: "if you just have a fresh MicroSD card … the site needs to guide
++// the user … now I can't even enter that command"). /setup/sd-card makes the card's first-boot files in the browser
++// with a setup key (server/setup-keys.js); the card's first boot runs the installer with it and the claim is approved
++// at once, with the choices made with the files: no code on a screen, nothing typed on the robot.
++const assert = require('assert');
++const { execFileSync } = require('child_process');
++const { boot, check, done } = require('./helpers/app');
++const card = require('../public/sd-card');
++
++(async () => {
++ const t = await boot();
++ const alex = t.network.newUser('alex');
++ const bob = t.network.newUser('bob');
++ const cookie = (user) => ({ Cookie: `ov_token=${t.network.signUser(user)}` });
++ const get = (p, user, headers = {}) => fetch(t.base + p, { redirect: 'manual', headers: { ...(user ? cookie(user) : {}), ...headers } });
++ const postJson = (p, user, body, headers = {}) => fetch(t.base + p, {
++ method: 'POST', redirect: 'manual', body: JSON.stringify(body),
++ headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...(user ? cookie(user) : {}), ...headers },
++ });
++ const start = (body) => t.call('POST', '/api/v1/claims', { token: null, body });
++ const poll = (c) => t.call('GET', `/api/v1/claims/${c.id}`, { token: null, headers: { 'X-OV-Claim-Secret': c.secret } });
++ const pi = { hostname: 'mecanum', hardware: { model: 'Raspberry Pi 4 Model B Rev 1.5', os: 'Debian GNU/Linux 13 (trixie)', arch: 'aarch64' } };
++ const choices = { name: 'Mecanum rover', profile_id: 'adeept.adr036.mecanum', access_policy: 'invite', live: true, hostname: 'mecanum' };
++ let made, robotId, firstCode;
++
++ await check('the page: the steps for anyone, the form once signed in, the model asked for chosen', async () => {
++ const anon = await (await get('/setup/sd-card')).text();
++ assert.ok(anon.includes('A robot from a blank microSD card') && anon.includes('Raspberry Pi OS Lite (64-bit)') && anon.includes('bootfs'));
++ assert.ok(anon.includes('href="/auth/login?next=%2Fsetup%2Fsd-card"') && !anon.includes('id="sd-form"'), 'signed out: a sign-in, no form');
++ const html = await (await get('/setup/sd-card?profile=adeept.adr036.mecanum', alex)).text();
++ assert.ok(html.includes('id="sd-form"') && html.includes('<script src="/panel/sd-card.js" defer></script>'));
++ assert.match(html, /<option value="adeept\.adr036\.mecanum" selected>/);
++ assert.ok(!/name="wifi_password"[^>]*value=/.test(html), 'no password ever comes from the server');
++ const js = await get('/panel/sd-card.js');
++ assert.strictEqual(js.status, 200);
++ assert.match(js.headers.get('content-type'), /javascript/);
++ const add = await (await get('/robots/new', alex)).text();
++ assert.ok(add.includes('href="/setup/sd-card"'), 'Add a robot points at it');
++ const robots = await (await get('/robots.txt')).text();
++ assert.ok(robots.includes('Disallow: /setup/sd-card/keys') && !robots.includes('Disallow: /setup/sd-card\n'));
++ });
++
++ await check('making the files: signed in, from this site, a listed model; the answer is a key shown once', async () => {
++ assert.strictEqual((await postJson('/setup/sd-card/keys', null, choices)).status, 401);
++ assert.strictEqual((await postJson('/setup/sd-card/keys', alex, choices, { Origin: 'https://evil.test' })).status, 403);
++ const bad = await postJson('/setup/sd-card/keys', alex, { ...choices, profile_id: 'no.such' });
++ assert.strictEqual(bad.status, 422);
++ assert.strictEqual((await bad.json()).error, 'bot.unknown_profile');
++ assert.strictEqual((await postJson('/setup/sd-card/keys', alex, { ...choices, hostname: 'Not A Host!' })).status, 422);
++ const r = await postJson('/setup/sd-card/keys', alex, choices);
++ assert.strictEqual(r.status, 201);
++ made = await r.json();
++ assert.match(made.id, /^osk_[0-9A-Z]{26}$/);
++ assert.match(made.key, /^osk_[A-Za-z0-9_-]{43}$/);
++ const row = await t.db.maybe('SELECT * FROM device_setup_keys WHERE id = $1', [made.id]);
++ assert.ok(row.secret_hash !== made.key && !JSON.stringify(row).includes(made.key), 'only the hash is kept');
++ assert.ok(new Date(made.expires_at).getTime() - Date.now() > 6.9 * 24 * 3600 * 1000, 'it lasts 7 days');
++ const s = await (await get(`/setup/sd-card/keys/${made.id}`, alex)).json();
++ assert.deepStrictEqual([s.state, s.name, s.hostname, s.robot_id], ['waiting', 'Mecanum rover', 'mecanum', null]);
++ assert.strictEqual((await get(`/setup/sd-card/keys/${made.id}`, bob)).status, 404, 'only its owner follows it');
++ });
++
++ await check('first boot: the claim is approved at once with the card\'s choices, no code on a screen', async () => {
++ const r = await start({ ...pi, setup_key: made.key });
++ assert.strictEqual(r.status, 201, r.text);
++ assert.strictEqual(r.json.approved, true);
++ const p = await poll(r.json);
++ assert.strictEqual(p.json.state, 'approved');
++ assert.deepStrictEqual([p.json.name, p.json.profile_id, p.json.driver, p.json.live], ['Mecanum rover', 'adeept.adr036.mecanum', 'adeept-mecanum', true]);
++ robotId = p.json.robot_id;
++ firstCode = p.json.pairing.code;
++ const robot = await t.db.maybe('SELECT owner_subject, access_policy FROM robots WHERE id = $1', [robotId]);
++ assert.deepStrictEqual([robot.owner_subject, robot.access_policy], [alex.subject, 'invite'], 'alex\'s robot, as chosen');
++ const s = await (await get(`/setup/sd-card/keys/${made.id}`, alex)).json();
++ assert.deepStrictEqual([s.state, s.robot_id], ['installing', robotId]);
++ });
++
++ await check('a retry before pairing (the install stopped half way) pairs the same robot with a fresh code', async () => {
++ const r = await start({ ...pi, setup_key: made.key });
++ assert.strictEqual(r.status, 201, r.text);
++ const p = await poll(r.json);
++ assert.strictEqual(p.json.robot_id, robotId, 'no second robot');
++ assert.notStrictEqual(p.json.pairing.code, firstCode, 'a fresh code (the first may have run out)');
++ assert.strictEqual(Number((await t.db.maybe('SELECT COUNT(*) AS n FROM robots WHERE owner_subject = $1', [alex.subject])).n), 1);
++ const paired = await t.call('POST', '/api/v1/pair', { token: null, body: { robot: robotId, code: p.json.pairing.code, device_kind: 'onboard', name: 'mecanum' } });
++ assert.strictEqual(paired.status, 201, paired.text);
++ const s = await (await get(`/setup/sd-card/keys/${made.id}`, alex)).json();
++ assert.strictEqual(s.state, 'paired');
++ });
++
++ await check('a spent, revoked, expired or made-up key adds nothing, and says what to do', async () => {
++ const spent = await start({ ...pi, setup_key: made.key });
++ assert.strictEqual(spent.status, 403);
++ assert.strictEqual(spent.json.code, 'bot.setup_key_invalid');
++ assert.match(spent.json.detail || spent.json.error, /setup\/sd-card/);
++ const other = await (await postJson('/setup/sd-card/keys', bob, { ...choices, name: 'Bob bot' })).json();
++ assert.strictEqual((await postJson(`/setup/sd-card/keys/${other.id}/revoke`, alex, {})).status, 404, 'not alex\'s to revoke');
++ assert.strictEqual((await postJson(`/setup/sd-card/keys/${other.id}/revoke`, bob, {})).status, 200);
++ assert.strictEqual((await start({ ...pi, setup_key: other.key })).status, 403);
++ const old = await (await postJson('/setup/sd-card/keys', bob, { ...choices, name: 'Old bot' })).json();
++ await t.db.query("UPDATE device_setup_keys SET expires_at = now() - interval '1 minute' WHERE id = $1", [old.id]);
++ assert.strictEqual((await start({ ...pi, setup_key: old.key })).status, 403);
++ assert.strictEqual((await (await get(`/setup/sd-card/keys/${old.id}`, bob)).json()).state, 'expired');
++ assert.strictEqual((await start({ ...pi, setup_key: 'osk_' + 'x'.repeat(43) })).status, 403);
++ });
++
++ await check('the card files: SHA-512 crypt as openssl makes it, YAML that says exactly what was typed', async () => {
++ for (const [pw, salt] of [['hunter2', 'saltsaltsaltsalt'], ['pässwörd "q" \\ & more', 'abc'], ['x'.repeat(70), 'Zz9./AbCdEfGhIj']]) {
++ let ref = null;
++ try { ref = execFileSync('openssl', ['passwd', '-6', '-salt', salt, pw]).toString().trim(); } catch { ref = null; }
++ const mine = await card.sha512Crypt(pw, salt);
++ if (ref) assert.strictEqual(mine, ref, `crypt of ${JSON.stringify(pw)}`);
++ assert.match(mine, new RegExp(`^\\$6\\$${salt.replace(/[.$]/g, '\\$&')}\\$[./0-9A-Za-z]{86}$`));
++ }
++ assert.strictEqual(card.yamlQuote('a "b" \\ c\td'), '"a \\"b\\" \\\\ c\\x09d"');
++ const ud = card.userData({ hostname: 'mecanum', username: 'robot', passwordHash: '$6$abc$xyz', sshKeys: ['ssh-ed25519 AAAA you@x', ''], timezone: 'America/Los_Angeles',
++ setupKey: made.key, server: 'https://openvibe.bot', robotName: 'Mecanum rover' });
++ assert.ok(ud.startsWith('#cloud-config\n'), 'cloud-init reads only a file that starts with #cloud-config');
++ assert.ok(ud.includes(` content: "${made.key}"`) && ud.includes('/etc/openvibe/setup-key') && ud.includes('permissions: "0600"'));
++ assert.ok(ud.includes('curl -fsSL https://openvibe.bot/install | sh -s -- --setup "$key"'));
++ assert.ok(ud.includes(' - [ systemctl, enable, --now, ssh ]') && ud.includes('systemd-run, --unit=openvibe-setup'));
++ assert.ok(/\nruncmd:\n/.test(ud) && ud.split('\nruncmd:').length === 2, 'one runcmd list (a second would replace the first)');
++ assert.throws(() => card.userData({ hostname: 'x', username: 'y', passwordHash: 'z', setupKey: 'nope' }), /setup key/);
++ const nc = card.networkConfig({ ssid: 'Home "5G": café', password: 'p@ss "w" \\', country: 'us', hidden: true });
++ assert.ok(nc.includes('regulatory-domain: "US"') && nc.includes(' "Home \\"5G\\": café":') && nc.includes(' hidden: true')
++ && nc.includes(' password: "p@ss \\"w\\" \\\\"'));
++ assert.ok(card.networkConfig({ ssid: 'Cafe', country: 'GB' }).includes('key-management: none'), 'an open network');
++ });
++
++ await t.close();
++ done();
++})();