openvibers/openvibe.bot Commits
The one command on the robot: a machine with no code asks to be added, its owner approves it on /link, it pairs and goes live by itself (#140)
Changes
@@@ -41,6 +41,7 @@ Sitemap entries use the release commit time for these code-backed pages.## Devices- A device (the machine that serves a robot) pairs with one pasted command, shown on the robot's pairing page: `curl -fsSL <BOT_INSTALLER_URL> | sh -s -- --robot rob_… --code XXXX-XXXX [--driver …]` (the Network form in `docs/protocol.md` §1). The `--driver` follows the robot's profile: `adeept` for `adeept.adr036`, `adeept-mecanum` for `adeept.adr036.mecanum`, `cozmo` for `cozmo`, `looi` for `looi` (the community's Generic LOOI driver, a Bluetooth bridge; OpenVibe.Node `docs/hardware-looi.md`); `ros2` for `ros2.diffdrive` and `ros2-holonomic` for `ros2.holonomic` (any ROS 2 robot on `cmd_vel`, through rosbridge or rclpy; OpenVibe.Node `plugins/ros2`); any other profile gets none (`server/domain/index.js` `driverForProfile`), the installer's dry-run `none`.++- **The one command on the robot** (`server/claims.js`, migration 0019): `curl -fsSL <BOT_INSTALLER_URL> | sh` with no robot and no code. The installer finds the kit on the machine (OpenVibe.Node `docs/install.md`) and starts a claim, `POST /api/v1/claims` (anyone, counted by address: a code like `KQ7M-2XRD`, a poll secret, 15 minutes). The owner enters the code at `/link` (signed in; the QR opens `/link?code=…`), sees what the machine found (shown escaped, it only pre-selects the model) and approves it with a name, a model the installer has a driver for (or the generic relay), who may drive, and **Show its camera on my OpenVibe.Live channel**, or denies it. Approving makes the robot exactly as Add robot does and keeps its pairing on the claim; the machine reads it with `GET /api/v1/claims/:id` and its `X-OV-Claim-Secret`, then pairs the usual way. Once it has paired (`domain.pairing.onPaired`) the pairing is dropped and the Live mirror is turned on if the owner asked. The approval page links to the owner's channel with `?bind_robot=rob_…`, where Live asks them to let the channel's chat drive it.- **A person's own machine that runs their jobs** (plan T14/T16) serves no robot. `openvibe.bot/machines` lists the person's machines (online, the job classes they run, the robot they serve if any), adds one with a one-time command, `curl -fsSL <BOT_INSTALLER_URL> | sh -s -- --network <Network> --pairing pair_… --code XXXX-XXXX --worker` (a Network pairing with ref `worker:<owner>`, so the device keeps `owner_subject` and has no robot; migration 0013), and removes one (revoked here and on Network). Every live machine with a job worker, a robot's included, is reported to Network as a `user-owned` resource offer naming its owner (`server/jobs/offers.js`: one offer per device, `worker:<class>` per class, `up` while connected and `down` otherwise; the whole set as source `bot`, once a minute and two seconds after a device comes, goes or changes what it advertises). Run places a person's job only on that person's machines, and the dispatcher refuses any other (`bot.not_owner`).- **A person's computer for their agents** (plan T14/T17, contracts 0.166.0): a machine installed with `--computer observe|ask|trusted|full` runs OpenVibe.Node's `computer` plugin as the person's own account, and the level set on the machine decides what may happen there (`ask` waits for `openvibe-node approve <id>`). `POST /api/v1/machines/:id/commands` sends `exec`, `fs` and `screen` (`bot.command@1`, the value checked against the contract) to it as its owner, or as their agent (`svc:actor` with `bot.computer.control`, acting as the owner) once the owner turned "Let my agent use this computer" on at `/machines` (migration 0014, `devices.agent_computer`). The answer comes back in `data` (the device socket takes frames up to 640 KB so it fits; a repeated id answers its result without the data). A machine whose plugin is off answers `bot.computer_off`. The audit keeps the program name and argument count, or the file op and path, never stdin, arguments or content. The machines page shows each machine's level, what waits for approval there, and the switch. OpenVibe.Watch (`svc:watch`, `bot.computer.control`, contracts 0.167.0) may send only `probe`: one GET or TCP connect from the machine to its own network, for a watch's `node` source. It works only where the owner turned on "Let my watches check things from this computer" (migration 0015, `devices.watch_probe`), and the audit keeps the probe's host, not its path. Every device's config lists the computer kinds, which only this route sends (a robot's operators never can).- **The live screen** (plan T14, contracts 0.182.0, OpenVibe.Node v0.12.0): the machine's owner, signed in as themselves, watches its screen at `/machines/:id/screen` or through `POST|GET|DELETE /api/v1/machines/:id/screen` (`server/machine-screen.js`).@@@ -61,5 +61,5 @@"server-side ONVIF cameras (profile camera.onvif, driver onvif, server/onvif): an in-process device beside the simulator attaches a camera configured with BOT_ONVIF_CAMERAS (its URL plus username_ref/password_ref secret references, never in the profile or in clear), writes a real kind server device row, translates ptz commands to ONVIF ContinuousMove/Stop with the profile deadline as a deadman, and carries the camera's GetStatus readout in telemetry; every request goes only to the configured host, with a timeout and no redirects","a person's own machines that run their jobs (/machines: add with a --worker command, list, remove); their worker offers reported to Network as user-owned offers naming the owner; jobs reach a machine only for its owner (plan T14/T16)"],-- "updated": "2026-10-17"++ "updated": "2026-10-20"}nnew file mode 100644@@@ -0,0 +1,26 @@++-- phase: expand++-- A device that asks to be added (the installer run with no pairing code: `curl -fsSL https://openvibe.bot/install | sh`).++-- It shows a code; a signed-in person enters it on /link, sees what the device found, and approves or denies it. An++-- approval makes the robot (server/claims.js) and leaves its pairing here for the device, which polls with its secret;++-- the pairing goes once the device has paired. Rows are short-lived (15 minutes to approve; pruned after a day).++CREATE TABLE IF NOT EXISTS device_claims (++ id text PRIMARY KEY, -- clm_…++ user_code_hash text NOT NULL UNIQUE, -- sha256 of the code the device shows (XXXX-XXXX, normalised)++ secret_hash text NOT NULL, -- sha256 of the device's poll secret++ hostname text NOT NULL,++ hardware jsonb NOT NULL DEFAULT '{}'::jsonb, -- what the installer found: shown (escaped), never acted on++ suggested_profile text, -- the catalogue profile that detection names, if any++ address text, -- the address the claim came from, shown to the approver++ state text NOT NULL DEFAULT 'pending', -- pending, approving, approved, denied, paired++ owner_subject text, -- who approved or denied it++ robot_id text, -- the robot the approval made++ pairing jsonb, -- that robot's pairing for the device, until it pairs++ want_live boolean NOT NULL DEFAULT false, -- the owner asked for the robot's video on their Live channel++ live_applied_at timestamptz,++ created_at timestamptz NOT NULL,++ expires_at timestamptz NOT NULL,++ decided_at timestamptz,++ paired_at timestamptz++);++CREATE INDEX IF NOT EXISTS device_claims_robot_idx ON device_claims (robot_id) WHERE robot_id IS NOT NULL;++CREATE INDEX IF NOT EXISTS device_claims_expires_idx ON device_claims (expires_at);@@@ -29,6 +29,8 @@ const TABLES = [{ table: 'pairing_codes', subject: 'created_by', file: null, erase: anonymize },{ table: 'robot_operators', subject: 'added_by', file: null, erase: anonymize },{ table: 'live_conversions', subject: 'owner_subject', file: null },++ // A device's request to be added (server/claims.js): who approved or denied it. Short-lived; erased with the account.++ { table: 'device_claims', subject: 'owner_subject', file: null },{ table: 'run_jobs', subject: 'subject', value: (usr) => [usr, `user:${usr}`], file: 'run-jobs.json', columns: ['id', 'class', 'state', 'wall_ms', 'exit_reason', 'created_at', 'finished_at'], erase: { keep: 'usage seconds reported to Billing' } },// Community robot profiles: the person's own submissions are exported; on deletion the ones still waiting, refused or// withdrawn go (extraErase) and an approved one stays in the catalogue, authorless (robots may use it).@@@ -31,6 +31,8 @@* | POST /robots/:id/streaming | bot.robot.manage | the owner |* | POST /jobs, /jobs/:id/cancel, GET /jobs/:id | bot.job.dispatch | — (services only) |* | POST /pair | — (one-time code) | the agent (code is the credential) |++ * | POST /claims | — (anyone) | a device asking to be added: a code |++ * | GET /claims/:id | — (X-OV-Claim-Secret) | that device: pending, or its pairing|** With BOT_PAIRING_AUTHORITY=network the pairing codes are Network's and POST /pair answers 410 bot.pairing_moved.*/@@@ -64,7 +66,7 @@ const MACHINE_SERVICES = {'svc:watch': { kinds: new Set(['probe']), flag: 'watch_probe', off: 'the owner has not let their watches check things from this computer' },};--function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, submissions = null, routines = null, webhooks = null }) {++function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, submissions = null, routines = null, webhooks = null, claims = null }) {const r = express.Router();r.use(limits.reads('bot.read'));// Control first (bot.resource.control), then the index (bot.resource.read) for everything else under /resources.@@@ -551,6 +553,24 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, submres.status(201).json({ device_id: result.device.id, credential: result.credential, ...domain.present.video(result), robot_id: json(result.device.robot_ids, [])[0], profile: result.profile });}));++ // ── A device asking to be added (server/claims.js): the installer run with no robot and no code ─++ // Anyone may start one: it only makes a code for a signed-in person to approve on /link. Only the secret the start++ // answered reads it back (a header of its own, so it is never taken for a token). Both are counted by address.++ const claimStart = limits('bot.claim', { minute: 6, hour: 40 });++ const claimPoll = limits('bot.claim.poll', { minute: 40, hour: 1200 });++ 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 });++ res.setHeader('Cache-Control', 'no-store');++ res.status(201).json(out);++ }));++ r.get('/claims/:id', claimPoll, wrap(async (req, res) => {++ needClaims();++ res.setHeader('Cache-Control', 'no-store');++ res.json(await claims.poll(req.params.id, String(req.headers['x-ov-claim-secret'] || '')));++ }));++// ── Bootstrap of a Network-paired machine (its node token, once after pairing) ────────────────// Binds the machine to its robot and issues its WHIP publish key; calling again re-issues the key (the// old one stops working) for the same device. POST /pair's answer without a credential.@@@ -37,6 +37,7 @@ const { createSimulator } = require('./sim');const { createOnvif } = require('./onvif');const { createApiAuth } = require('./api/auth');const { v1Router } = require('./api/v1');++const { createClaims } = require('./claims');const { createJobs } = require('./jobs');const { createActorLimits } = require('./api/actor-limits');const { createSessionRoutes, viewerMiddleware } = require('./web/session');@@@ -86,6 +87,10 @@ function createApp(opts = {}) {onEvent: (type, robotId) => { if (type === 'bot.robot.online' && routines) routines.onOnline(robotId).catch((e) => log.warn(`[Bot] online routines: ${e.message}`)); } });routines = createRoutines({ db, domain, hub, now, log, tickMs: config.automation.routineTickMs });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 });++ 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.const jobs = createJobs({ db: () => domain.db, hub, usage, log, now });@@@ -159,7 +164,7 @@ function createApp(opts = {}) {const limits = createActorLimits({ config, valkey, now: opts.limitsNow || now, registry: metrics.registry, log });// A computer command may carry a file to write (≤ 256 KB, base64): its route alone takes a larger body.app.use(/^\/api\/v1\/machines\/[^/]+\/commands$/, express.json({ limit: '400kb' }));-- app.use('/api/v1', express.json({ limit: '64kb' }), (req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); }, apiAuth.middleware, v1Router({ domain, apiAuth, limits, hub, jobs, config, submissions, routines, webhooks }));++ app.use('/api/v1', express.json({ limit: '64kb' }), (req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); }, apiAuth.middleware, v1Router({ domain, apiAuth, limits, hub, jobs, config, submissions, routines, webhooks, claims }));app.use('/auth', createSessionRoutes(config, userAuth, { fetchImpl }));// ── The legal pages: Terms, Privacy and DMCA on openvibe.bot itself (openvibe-shared/legal). ──// The document carries its own inline theme and OpenVibe Frame boot, which the strict site-wide CSP@@@ -168,7 +173,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, 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, 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 });@@@ -187,7 +192,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 });++ Object.assign(app.locals, { config, db, valkey, domain, keys, outbox, usage, hub, jobs, offers, sim, onvif, userAuth, metrics, submissions, routines, webhooks, claims });return app;}nnew file mode 100644@@@ -0,0 +1,171 @@++'use strict';++/**++ * A device that asks to be added: the one command on the machine (plan T15 "five-minute path", owner 2026-10-17: "I++ * should be able to run a single command on my raspi … ready to go live and controllable").++ *++ * curl -fsSL https://openvibe.bot/install | sh (no robot, no code)++ *++ * The installer looks at the machine (the kit on its I2C bus, the camera), then starts a claim here and shows the++ * person a code and openvibe.bot/link. Signed in there, they see what the device found and approve it (name, model,++ * who may drive, its video on their Live channel) or deny it. The approval makes the robot exactly as Add robot does++ * (domain.robots.create) and keeps that robot's pairing on the claim. The device polls with the secret only it holds,++ * gets the pairing and pairs the usual way (POST /pair, or Network's form), so pairing itself is unchanged. When it has++ * 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, … }++ * 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 }++ * deny(id, owner)++ * afterPair(robotId) → the onPaired hook++ * prune()++ *++ * The code is 8 Crockford characters like a pairing code (only its hash is stored); the secret is 32 random bytes++ * (hash stored). What the device reports is untrusted: it is cut to known fields and lengths, shown escaped, and only++ * ever pre-selects a model the person can change. A claim lasts 15 minutes.++ */++const { prefixedId, iso, token, hashSecret, secretEquals, fail, text } = require('./util');++const { newCode, formatCode, normaliseCode, isCodeShape } = require('./domain');++const { getProfile } = require('./profiles');++++const TTL_MS = 15 * 60 * 1000;++const POLL_S = 3;++const KEEP_MS = 24 * 3600 * 1000;++const HARDWARE_FIELDS = { model: 80, os: 80, arch: 20, kit: 60, kit_name: 80, camera: 80 };++++const clip = (v, max) => (typeof v === 'string' ? v.replace(/[\u0000-\u001f\u007f]/g, ' ').trim().slice(0, max) : '');++++/** The hostname as a robot name: what a hostname may hold, else "robot". */++function cleanHostname(v) {++ const s = clip(v, 63).replace(/[^A-Za-z0-9._-]/g, '');++ return s || 'robot';++}++++/** Only the fields the approval page shows, each cut to length; `found` is a short list ("PCA9685 at 0x5f"). */++function cleanHardware(h) {++ const out = {};++ if (!h || typeof h !== 'object' || Array.isArray(h)) return out;++ for (const [k, max] of Object.entries(HARDWARE_FIELDS)) { const v = clip(h[k], max); if (v) out[k] = v; }++ if (Array.isArray(h.found)) {++ const found = h.found.map((x) => clip(x, 60)).filter(Boolean).slice(0, 8);++ if (found.length) out.found = found;++ }++ return out;++}++++function createClaims({ db, domain, config, now = () => Date.now(), log = console }) {++ const base = String(config.baseUrl || 'https://openvibe.bot').replace(/\/+$/, '');++++ async function start({ hostname, hardware, address = null } = {}) {++ 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());++ const secret = token(32);++ const at = now();++ // A fresh code until one is free: two live claims never share a code.++ for (let tries = 0; tries < 5; tries++) {++ const code = newCode();++ const row = await db.maybe(++ `INSERT INTO device_claims (id, user_code_hash, secret_hash, hostname, hardware, suggested_profile, address, created_at, expires_at)++ VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7, $8, $9) ON CONFLICT (user_code_hash) DO NOTHING RETURNING id`,++ [id, hashSecret(code), hashSecret(secret), cleanHostname(hostname), JSON.stringify(hw), kit ? kit.profile.id : null,++ address ? clip(String(address), 64) : null, iso(at), iso(at + TTL_MS)]);++ if (row) {++ const userCode = formatCode(code);++ 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}`,++ };++ }++ }++ fail(503, 'bot.unavailable', 'could not make a claim code; try again');++ return null;++ }++++ const live = (row) => row && new Date(row.expires_at).getTime() > now();++++ async function poll(id, secret) {++ const row = typeof id === 'string' && /^clm_[0-9A-Z]{26}$/.test(id) ? await db.maybe('SELECT * FROM device_claims WHERE id = $1', [id]) : null;++ // A wrong secret and a claim that does not exist answer the same.++ if (!row || typeof secret !== 'string' || !secretEquals(hashSecret(secret), row.secret_hash)) fail(404, 'bot.claim_not_found', 'no such claim');++ if (row.state === 'denied') return { state: 'denied' };++ if (row.state === 'paired') return { state: 'paired', robot_id: row.robot_id };++ if (row.state === 'approved' && row.pairing) {++ const robot = await domain.robots.get(row.robot_id);++ const p = row.pairing;++ return {++ state: 'approved', robot_id: row.robot_id, name: robot ? robot.name : null, profile_id: robot ? robot.profile_id : null,++ driver: robot ? domain.pairing.driverForProfile(robot.profile_id) : 'none',++ pairing: p.pairing_id ? { network: config.network && config.network.url, pairing_id: p.pairing_id, code: p.code, expires_at: p.expires_at }++ : { robot: row.robot_id, code: p.code, expires_at: p.expires_at },++ panel_url: `${base}/panel/${row.robot_id}`, live: !!row.want_live,++ };++ }++ if (!live(row)) return { state: 'expired' };++ return { state: 'pending', expires_at: new Date(row.expires_at).toISOString(), interval: POLL_S };++ }++++ async function find(userCode) {++ const normal = normaliseCode(userCode);++ if (!isCodeShape(normal)) return null;++ const row = await db.maybe("SELECT * FROM device_claims WHERE user_code_hash = $1 AND state = 'pending'", [hashSecret(normal)]);++ return live(row) ? row : null;++ }++++ async function pending(id) {++ const row = typeof id === 'string' ? await db.maybe('SELECT * FROM device_claims WHERE id = $1', [id]) : null;++ if (!row || row.state !== 'pending' || !live(row)) fail(404, 'bot.claim_not_found', 'that device is no longer waiting (a code lasts 15 minutes): run the command on it again');++ return row;++ }++++ async function approve(id, owner, { name, profile_id, access_policy = 'private', live: wantLive = false } = {}) {++ await pending(id);++ // Taken first, so two approvals at once cannot make two robots; given back if making the robot fails.++ const taken = await db.maybe("UPDATE device_claims SET state = 'approving', owner_subject = $2 WHERE id = $1 AND state = 'pending' RETURNING *", [id, owner]);++ if (!taken) fail(409, 'bot.claim_taken', 'that device was just approved or denied');++ let made;++ try {++ made = await domain.robots.create({ owner, name: text(name, 'name', 80) || taken.hostname, profile_id, access_policy });++ } catch (e) {++ await db.query("UPDATE device_claims SET state = 'pending', owner_subject = NULL WHERE id = $1 AND state = 'approving'", [id]);++ throw e;++ }++ const p = made.pairing || {};++ const claim = await db.maybe(++ `UPDATE device_claims SET state = 'approved', robot_id = $2, pairing = $3::jsonb, want_live = $4, decided_at = $5++ WHERE id = $1 RETURNING *`,++ [id, made.robot.id, JSON.stringify({ code: p.code, expires_at: p.expires_at, pairing_id: p.pairing_id || null }), !!wantLive, iso(now())]);++ return { robot: made.robot, claim };++ }++++ 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())]);++ if (!row) fail(409, 'bot.claim_taken', 'that device was just approved or denied');++ }++++ /** The robot paired: drop the pairing, and turn on what its owner asked for when they approved it. */++ async function afterPair(robotId) {++ 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())]);++ if (!row || !row.want_live || row.live_applied_at) return;++ try {++ await domain.streaming.set(robotId, { subject: row.owner_subject, kind: 'user' }, { to: 'live', on: true });++ await db.query('UPDATE device_claims SET live_applied_at = $2 WHERE id = $1', [row.id, iso(now())]);++ } catch (e) {++ log.warn(`[Bot] claim ${row.id}: could not put ${robotId} on Live: ${e.message}`);++ }++ }++++ async function prune() {++ await db.query("DELETE FROM device_claims WHERE expires_at < $1 AND state <> 'approved'", [iso(now() - KEEP_MS)]);++ // An approval whose device never paired keeps its pairing until the pairing itself is long dead.++ await db.query("DELETE FROM device_claims WHERE state = 'approved' AND decided_at < $1", [iso(now() - KEEP_MS)]);++ }++++ return { start, poll, find, waiting: pending, approve, deny, afterPair, prune, TTL_MS };++}++++module.exports = { createClaims, cleanHardware, cleanHostname, CLAIM_TTL_MS: TTL_MS };@@@ -298,6 +298,15 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =}// ── Pairing (ADR-043 decision 2) ──────────────────────────────────────────────────────────────++ // What runs once a robot's device has paired (its stream exists from then on): a device claim's choices++ // (server/claims.js). A hook that fails is logged; the pairing stands.++ const pairedHooks = [];++ const onPaired = (fn) => { pairedHooks.push(fn); };++ async function paired(robotId) {++ for (const fn of pairedHooks) {++ try { await fn(robotId); } catch (e) { log.warn(`[Bot] after pairing ${robotId}: ${e.message}`); }++ }++ }const networkPairing = () => config.pairing.authority === 'network';async function createPairingCode(robotId, createdBy, installerUrl, robotRow = null) {const robot = robotRow || await getRobot(robotId);@@@ -383,6 +392,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =return { device, credential, issued, robot_id: row.robot_id };});if (result.error) fail(result.error[0], result.error[1], result.error[2]);++ await paired(result.robot_id);const profile = await getProfile(db, (await getRobot(result.robot_id)).profile_id);return { device: result.device, credential: result.credential, ...videoKey(result.issued), profile: profile ? profile.profile : null };}@@@ -767,6 +777,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =[id, !!issued, issued ? issued.hint : null, iso(now())]);});if (!device) fail(404, 'bot.device_not_found', 'no live device');++ if (robot) await paired(robot.id); // a Network-paired machine's first key (and any later one)const profile = robot ? await getProfile(db, robot.profile_id) : null;return { device, ...videoKey(issued), profile: profile ? profile.profile : null };}@@@ -1159,7 +1170,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =robots: { create: createRobot, list: listRobots, get: getRobot, update: updateRobot, setEmbedPublic, setListed, listListed, setAgentControl, remove: removeRobot,localProfile, saveLocalProfile, useCatalogueProfile },members: { roleOf, add: addOperator, remove: removeOperator, list: listOperators },-- pairing: { create: createPairingCode, redeem, prune: prunePairingCodes, installerCommand, driverForProfile, whipUrl },++ pairing: { create: createPairingCode, redeem, prune: prunePairingCodes, installerCommand, driverForProfile, whipUrl, onPaired },devices: { byCredential, bindNode, issuePublishKey, revokeVideo, updateDeclared, revokeNode, ensureServerDevice, get: getDevice, listForRobot: listDevicesForRobot, rotate: rotateDevice, revoke: revokeDevice, revokeByNode, touchSeen, setOnline, ownerOf, onChange },machines: { mint: mintMachine, list: listMachines, workerRef, setAgentComputer, setWatchProbe,screen: { key: screenKey, live: screenLive, end: screenEnd, archive: screenArchive } },@@@ -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 } = app.locals;++ const { domain, keys, outbox, usage, hub, offers, sim, onvif, routines, webhooks, claims } = app.locals;keys.start().catch(() => { /* logged by the JWKS client */ });const timers = [];@@@ -42,6 +42,7 @@ async function main() {const every = (ms, fn) => { const t = setInterval(() => { Promise.resolve().then(fn).catch((e) => console.warn('[Bot] job:', e.message)); }, ms); t.unref(); timers.push(t); };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(1000, async () => { for (const robotId of await domain.queue.sweep()) hub.broadcast(robotId); });outbox.start();usage.start();@@@ -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" 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">${error ? `<p class="dir-error" role="alert">${esc(error)}</p>` : ''}<label class="dir-field"><span class="dir-field-label">Name</span><input type="text" name="name" maxlength="80" required autocomplete="off" placeholder="e.g. Kitchen rover" value="${esc(values.name || '')}"></label>@@@ -15,6 +15,8 @@* renderWidget(widget, { profile, allowed_commands })* renderRobotsPage({ robots, profiles, error, values })* renderPairingPage({ robot, pairing, profile })++ * renderLinkPage({ code, error, installer }), renderClaimPage({ claim, models, values, error }),++ * renderClaimApproved({ robot, profile, live, chatUrl }) /link: a device that asked to be added (server/claims.js)* camerasOf(profile)* esc(value)*/@@@ -578,6 +580,76 @@ ${first}return page(`Pair ${robot.name}`, body, { scripts: ['/panel/panel.js'] });}++/**++ * /link (server/claims.js): the code a device shows after `curl -fsSL https://openvibe.bot/install | sh` with no code.++ * renderLinkPage asks for it; renderClaimPage shows what that device found and the choices its robot starts with;++ * renderClaimApproved follows the device as it pairs on its own.++ */++function renderLinkPage({ code = '', error = null, installer = 'curl -fsSL https://openvibe.bot/install | sh' } = {}) {++ const body = `${topbar('<span class="crumb">Add a device</span>')}++<main class="page link">++<h1>Add a device</h1>++<form class="card" method="get" action="/link">++<p>Enter the code the robot's computer shows. It shows one when you run this on it:</p>++<pre tabindex="0" class="installer"><code>${esc(installer)}</code></pre>++${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>++</main>`;++ return page('Add a device', body);++}++++function renderClaimPage({ claim, models = [], values = {}, error = null, now = Date.now() }) {++ const hw = claim.hardware || {};++ const minutes = (ms) => Math.max(0, Math.round(ms / 60000));++ const asked = minutes(now - new Date(claim.created_at).getTime());++ const left = minutes(new Date(claim.expires_at).getTime() - now);++ const suggested = models.some((m) => m.id === claim.suggested_profile) ? claim.suggested_profile : null;++ const chosen = values.profile_id || suggested || (models[0] && models[0].id);++ const facts = [++ ['Computer', [hw.model, hw.os, hw.arch].filter(Boolean).join(' · ') || 'not reported'],++ ['It found', (hw.found && hw.found.length ? hw.found.join(', ') : '') || hw.kit_name || 'no kit it knows'],++ ['Camera', hw.camera || 'none found'],++ ['Asked', `${asked < 1 ? 'just now' : `${asked} min ago`}${claim.address ? ` from ${claim.address}` : ''}`],++ ];++ const option = (m) => `<option value="${esc(m.id)}"${m.id === chosen ? ' selected' : ''}>${esc(m.name)}${m.id === suggested ? ' (found on it)' : ''}</option>`;++ const policy = values.access_policy || 'private';++ const live = values.live !== false;++ const body = `${topbar('<span class="crumb">Add a device</span>')}++<main class="page link">++<h1>Add ${esc(claim.hostname)}?</h1>++<p class="muted">Only approve a device you set up yourself, just now. If you did not run the command on it, deny it.</p>++<dl class="card facts">${facts.map(([k, v]) => `<dt>${esc(k)}</dt><dd>${esc(v)}</dd>`).join('')}</dl>++<form class="card" method="post" action="/link/${esc(claim.id)}/approve">++${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" value="${esc(values.name != null ? values.name : claim.hostname)}"></label>++<label class="field"><span class="field-label">Model</span><select name="profile_id">${models.map(option).join('')}</select></label>++<label class="field"><span class="field-label">Who may drive</span><select name="access_policy">${POLICIES.map(([v, label]) => `<option value="${esc(v)}"${v === policy ? ' selected' : ''}>${esc(label)}</option>`).join('')}</select></label>++<label class="field check"><input type="checkbox" name="live" value="on"${live ? ' checked' : ''}> <span>Show its camera on my OpenVibe.Live channel</span></label>++<button type="submit" class="primary">Approve</button>++</form>++<form method="post" action="/link/${esc(claim.id)}/deny"><button type="submit" class="quiet">Deny</button> <span class="muted">This code works for ${left} more minute${left === 1 ? '' : 's'}.</span></form>++</main>`;++ return page(`Add ${claim.hostname}`, body);++}++++function renderClaimApproved({ robot, profile = null, live = false, chatUrl = null }) {++ const body = `${topbar(`<span class="crumb">${esc(robot.name)}</span>`)}++<main class="page pairing" data-pair-robot="${esc(robot.id)}">++<h1>${esc(robot.name)} is yours</h1>++<p class="pair-status" data-pair-status data-state="waiting" role="status" aria-live="polite"><span class="dot"></span><span data-pair-text>Waiting for the device.</span> <a class="button primary" href="/panel/${esc(robot.id)}" data-pair-open hidden>Open the panel</a></p>++<ol class="steps">++<li class="card"><h2>The device carries on by itself${profile ? ` <span class="pill">${esc(profile.name)}</span>` : ''}</h2>++<p class="muted">It pairs, starts its driver and its camera, and prints its panel link. This page turns green when it connects.</p></li>++${live ? '<li class="card"><h2>On your Live channel</h2><p class="muted">Its camera goes on your OpenVibe.Live channel once it is streaming. Turn that off any time on its panel.</p></li>' : ''}++${chatUrl ? `<li class="card"><h2>Drive it from your chat</h2><p class="muted">Let your channel's chat commands (!forward, !left, !say …) drive this robot.</p><p><a class="button" href="${esc(chatUrl)}">Use it in my OpenVibe.Live chat</a></p></li>` : ''}++</ol>++<p class="muted"><a href="/panel/${esc(robot.id)}">Open the panel</a> any time.</p>++</main>`;++ return page(`${robot.name} is yours`, body, { scripts: ['/panel/panel.js'] });++}++/*** /machines (plan T14/T16): a person's own machines that run jobs for them, and the command that adds one. A machine* here runs its owner's jobs only (Bot refuses anyone else's), connects out, and keeps its local limits and kill switch.@@@ -709,4 +781,4 @@ function renderPhonePage() {return page('This phone is a robot', body, { scripts: ['/panel/phone-device.js'], bodyClass: 'phone-page', description: 'Turn a phone into a robot on OpenVibe.Bot from its browser: its camera, voice and face, with nothing to install.', canonical: '/device/phone' });}--module.exports = { page, renderPanel, renderPhonePage, renderMachinesPage, renderMachineScreenPage, panelScripts, isSim, TRY_DESCRIPTION, renderEmbedRefused, renderDrivePage, renderWidget, renderRobotsPage, renderPairingPage, camerasOf, esc };++module.exports = { page, renderPanel, renderPhonePage, renderMachinesPage, renderMachineScreenPage, panelScripts, isSim, TRY_DESCRIPTION, renderEmbedRefused, renderDrivePage, renderWidget, renderRobotsPage, renderPairingPage, renderLinkPage, renderClaimPage, renderClaimApproved, camerasOf, esc };@@@ -36,7 +36,7 @@ const { getProfile, listProfiles, loadProfiles } = require('../profiles');const { frameAncestors } = require('../config');const { BotError } = require('../util');const kits = require('../kits');--const { renderPanel, renderEmbedRefused, renderDrivePage, renderPairingPage, renderPhonePage, renderMachinesPage, renderMachineScreenPage } = require('./render');++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 { checkProfile } = require('../profiles');@@@ -140,11 +140,11 @@ const PANEL_CSP = ["default-src 'self'", `script-src 'self' ${PANEL_SCRIPT_SRC}`const embedCsp = (config) => ["default-src 'self'", `script-src 'self' ${PANEL_SCRIPT_SRC}`, `connect-src 'self' ${PANEL_CONNECT_SRC}`,`frame-ancestors ${frameAncestors(config)}`, "object-src 'none'", "base-uri 'self'"].join('; ');--function createWebRoutes(config, { domain = null, sim = null, onvif = null, limits = null, identity = null, hub = null, log = console, submissions = null, routines = null, webhooks = 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, releaseLastmod } = {}) {const r = express.Router();// The public front page is crawlable; sign-in, the API and every signed-in or per-robot page are not.r.get('/robots.txt', (req, res) => res.type('text/plain').send(['User-agent: *', 'Allow: /$', 'Disallow: /auth/', 'Disallow: /api/',-- 'Disallow: /robots/new', 'Disallow: /robots/submit', 'Disallow: /robots/submissions', 'Disallow: /pair/', 'Disallow: /panel/', 'Disallow: /install', `Sitemap: ${config.baseUrl}/sitemap.xml`, ''].join('\n')));++ 'Disallow: /robots/new', 'Disallow: /robots/submit', 'Disallow: /robots/submissions', 'Disallow: /pair/', 'Disallow: /panel/', 'Disallow: /install', '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.@@@ -433,6 +433,62 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limireturn res.status(201).type('html').send(renderPairingPage({ robot: domain.present.robot(robot), pairing, profile, phoneUrl: phoneUrl(robot.id, pairing) }));}));++ // ── A device that asked to be added (server/claims.js): its code, what it found, approve or deny ──++ // Looking a code up is counted per person like Add robot, so codes cannot be guessed from here either.++ const lookup = limits++ ? [(req, res, next) => { if (req.viewer && !req.principal) req.principal = req.viewer; next(); }, limits('bot.claim.lookup', { minute: 20, hour: 200 })]++ : [];++ // What a claimed device can be: a model the installer has a driver for, or the generic relay (your own program).++ const claimModels = async () => (await catalogue())++ .filter((m) => !m.id.startsWith('local.') && (domain.pairing.driverForProfile(m.id) !== 'none' || m.id === 'relay.generic'));++ const linkInstaller = () => `curl -fsSL ${config.installer.scriptUrl} | sh`;++ const needClaims = () => { if (!claims) throw new BotError(404, 'bot.not_found', 'adding a device by code is not available here'); };++ r.get('/link', ...lookup, page(async (req, res) => {++ requireUser(req);++ needClaims();++ const code = typeof req.query.code === 'string' ? req.query.code.slice(0, 20) : '';++ if (!code) return res.type('html').send(renderLinkPage({ installer: linkInstaller() }));++ const claim = await claims.find(code);++ if (!claim) {++ return res.status(404).type('html').send(renderLinkPage({ code, installer: linkInstaller(),++ error: 'No device is waiting with that code. A code lasts 15 minutes: run the command on the device again for a new one.' }));++ }++ return res.type('html').send(renderClaimPage({ claim, models: await claimModels() }));++ }));++ r.post('/link/:id/approve', ...manage, express.urlencoded({ extended: false, limit: '4kb' }), page(async (req, res) => {++ const me = requireUser(req);++ needClaims();++ if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');++ const body = req.body || {};++ const values = { name: typeof body.name === 'string' ? body.name : '', profile_id: String(body.profile_id || ''),++ access_policy: String(body.access_policy || 'private'), live: body.live === 'on' };++ const models = await claimModels();++ let made;++ try {++ if (!models.some((m) => m.id === values.profile_id)) throw new BotError(422, 'bot.unknown_profile', 'choose one of the models listed');++ made = await claims.approve(req.params.id, me.subject, values);++ } catch (e) {++ if (e.status === 422) {++ const claim = await claims.waiting(req.params.id);++ return res.status(422).type('html').send(renderClaimPage({ claim, models, values, error: e.detail || e.code }));++ }++ throw e;++ }++ const profile = await profileOf(made.robot);++ // Their Live channel (the username is the same on every site) binds the robot to its chat after they confirm there.++ const liveOrigin = (config.embed && config.embed.origins && config.embed.origins[0]) || null;++ const username = req.viewerClaims && typeof req.viewerClaims.username === 'string' ? req.viewerClaims.username : '';++ const chatUrl = liveOrigin && username ? `${liveOrigin}/@${encodeURIComponent(username)}?bind_robot=${encodeURIComponent(made.robot.id)}` : null;++ return res.status(201).type('html').send(renderClaimApproved({ robot: domain.present.robot(made.robot), profile, live: values.live, chatUrl }));++ }));++ r.post('/link/:id/deny', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {++ const me = requireUser(req);++ needClaims();++ if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');++ await claims.deny(req.params.id, me.subject);++ return res.type('html').send(renderLinkPage({ installer: linkInstaller(), error: 'Denied. That device was told no and added nothing.' }));++ }));++// 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'];nnew file mode 100644@@@ -0,0 +1,150 @@++'use strict';++// A device that asks to be added (server/claims.js): the installer run with no code starts a claim, the owner approves++// it on /link, the device collects the pairing of the robot that made and pairs the usual way, and what the owner chose++// (its video on their Live channel) is turned on once it has paired. Owner, 2026-10-17: "I should be able to run a++// single command on my raspi … ready to go live and controllable".++const assert = require('assert');++const { boot, check, done } = require('./helpers/app');++++(async () => {++ const t = await boot();++ const alex = t.network.newUser('alex');++ const bob = t.network.newUser('bob');++ const cookie = (user) => ({ Cookie: `ov_token=${t.network.signUser(user)}` });++ const get = (p, user) => fetch(t.base + p, { redirect: 'manual', headers: user ? cookie(user) : {} });++ const post = (p, user, body, headers = {}) => fetch(t.base + p, {++ method: 'POST', redirect: 'manual', body,++ headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...(user ? cookie(user) : {}), ...headers },++ });++ const start = (body) => t.call('POST', '/api/v1/claims', { token: null, body });++ const poll = (c, secret = c.secret) => t.call('GET', `/api/v1/claims/${c.id}`, { token: null, headers: { 'X-OV-Claim-Secret': secret } });++ const streamOf = (robotId) => [...t.openre.streams.values()].find((s) => s.refs.some((r) => r.type === 'robot' && r.id === robotId));++ // What OpenVibe.Node's installer sends after looking at an Adeept ADR036 on a Pi 4.++ const pi = {++ hostname: 'raspi',++ hardware: { model: 'Raspberry Pi 4 Model B Rev 1.4', os: 'Debian GNU/Linux 12 (bookworm)', arch: 'aarch64', kit: 'adeept.adr036',++ kit_name: 'Adeept ADR036 4WD', found: ['PCA9685 at 0x5f', 'ADS7830 at 0x48'], camera: 'imx219', password: 'hunter2' },++ };++ let claim, robotId;++++ await check('a device starts a claim with no token: a code, a secret, and where to enter the code', async () => {++ const r = await start(pi);++ assert.strictEqual(r.status, 201, r.text);++ claim = r.json;++ assert.match(claim.id, /^clm_[0-9A-Z]{26}$/);++ assert.match(claim.user_code, /^[0-9A-HJKMNP-TV-Z]{4}-[0-9A-HJKMNP-TV-Z]{4}$/);++ assert.ok(claim.secret.length >= 40 && claim.interval >= 1);++ assert.strictEqual(claim.verification_url_complete, `${t.config.baseUrl}/link?code=${claim.user_code}`);++ const row = await t.db.maybe('SELECT * FROM device_claims WHERE id = $1', [claim.id]);++ assert.ok(row.user_code_hash !== claim.user_code && row.secret_hash !== claim.secret, 'only hashes are kept');++ assert.ok(!('password' in row.hardware), 'only the fields the page shows are kept');++ assert.strictEqual(row.suggested_profile, 'adeept.adr036');++ });++++ await check('polling: the right secret sees pending, a wrong one the same 404 as no claim at all', async () => {++ assert.strictEqual((await poll(claim)).json.state, 'pending');++ assert.strictEqual((await poll(claim, 'nope')).status, 404);++ assert.strictEqual((await poll({ id: 'clm_01JZ00000000000000000000AA', secret: claim.secret })).status, 404);++ });++++ await check('/link: sign in first, then the device, what it found, and its choices', async () => {++ const out = await get(`/link?code=${claim.user_code}`);++ assert.strictEqual(out.status, 302);++ assert.match(out.headers.get('location'), /^\/auth\/login\?next=%2Flink%3Fcode%3D/);++ const r = await get(`/link?code=${claim.user_code.toLowerCase().replace('-', ' ')}`, alex);++ assert.strictEqual(r.status, 200);++ const html = await r.text();++ assert.ok(html.includes('Add raspi?') && html.includes('PCA9685 at 0x5f, ADS7830 at 0x48') && html.includes('imx219'));++ assert.match(html, /<option value="adeept\.adr036" selected>[^<]*\(found on it\)<\/option>/);++ assert.ok(html.includes('value="adeept.adr036.mecanum"'), 'the mecanum chassis looks the same on the bus: the owner picks it');++ assert.ok(!html.includes('value="sim.rover"') && !html.includes('value="camera.onvif"'), 'only what a device can be');++ assert.match(html, /name="live" value="on" checked/);++ assert.ok(html.includes(`action="/link/${claim.id}/approve"`) && html.includes(`action="/link/${claim.id}/deny"`));++ const wrong = await get('/link?code=ZZZZ-ZZZZ', alex);++ assert.strictEqual(wrong.status, 404);++ assert.ok((await wrong.text()).includes('No device is waiting with that code'));++ assert.ok((await (await get('/link', alex)).text()).includes(`curl -fsSL ${t.config.installer.scriptUrl} | sh`));++ });++++ await check('what a device reports is shown escaped and its hostname cut to a hostname', async () => {++ const evil = (await start({ hostname: '<b>pi</b>', hardware: { found: ['<img src=x onerror=alert(1)>'], kit: 'local.rob_x' } })).json;++ const html = await (await get(`/link?code=${evil.user_code}`, alex)).text();++ assert.ok(html.includes('<img src=x onerror=alert(1)>') && !html.includes('<img src=x'));++ assert.ok(html.includes('Add bpib?'), 'a hostname keeps only hostname characters');++ assert.strictEqual((await t.db.maybe('SELECT suggested_profile FROM device_claims WHERE id = $1', [evil.id])).suggested_profile, null);++ });++++ await check('approving: same-site only and a listed model; then the robot is the approver\'s', async () => {++ const form = 'name=Garage+rover&profile_id=adeept.adr036&access_policy=private&live=on';++ assert.strictEqual((await post(`/link/${claim.id}/approve`, alex, form, { Origin: 'https://evil.test' })).status, 403);++ const bad = await post(`/link/${claim.id}/approve`, alex, form.replace('adeept.adr036', 'sim.rover'));++ assert.strictEqual(bad.status, 422);++ assert.ok((await bad.text()).includes('choose one of the models listed'));++ const ok = await post(`/link/${claim.id}/approve`, alex, form);++ assert.strictEqual(ok.status, 201);++ const html = await ok.text();++ const robot = (await t.domain.robots.list(alex.subject))[0];++ robotId = robot.id;++ assert.deepStrictEqual([robot.name, robot.profile_id, robot.access_policy], ['Garage rover', 'adeept.adr036', 'private']);++ assert.ok(html.includes(`data-pair-robot="${robotId}"`) && html.includes('On your Live channel'));++ assert.ok(html.includes(`href="https://openvibe.live/@alex?bind_robot=${robotId}"`), 'and the way to drive it from their chat');++ assert.strictEqual((await post(`/link/${claim.id}/approve`, alex, form)).status, 404, 'a claim is approved once');++ assert.strictEqual((await get(`/link?code=${claim.user_code}`, alex)).status, 404, 'and its code is spent');++ });++++ await check('the device collects the pairing, pairs the usual way, and only then goes on Live', async () => {++ const r = await poll(claim);++ assert.strictEqual(r.json.state, 'approved');++ assert.deepStrictEqual([r.json.robot_id, r.json.driver, r.json.profile_id, r.json.name], [robotId, 'adeept', 'adeept.adr036', 'Garage rover']);++ assert.strictEqual(r.json.pairing.robot, robotId);++ assert.match(r.json.pairing.code, /^[0-9A-Z]{4}-[0-9A-Z]{4}$/);++ assert.strictEqual(r.json.panel_url, `${t.config.baseUrl}/panel/${robotId}`);++ assert.strictEqual(r.json.live, true, 'the device can say where it shows');++ assert.strictEqual((await poll(claim)).json.pairing.code, r.json.pairing.code, 'the device may ask again until it has paired');++ const paired = await t.call('POST', '/api/v1/pair', { token: null, body: { robot: robotId, code: r.json.pairing.code, device_kind: 'onboard', name: 'raspi' } });++ assert.strictEqual(paired.status, 201, paired.text);++ assert.strictEqual(streamOf(robotId).mirror_to_live, true, 'the owner asked for its video on their channel');++ const row = await t.db.maybe('SELECT state, pairing, live_applied_at FROM device_claims WHERE id = $1', [claim.id]);++ assert.deepStrictEqual([row.state, row.pairing], ['paired', null], 'the pairing is gone once used');++ assert.ok(row.live_applied_at);++ assert.deepStrictEqual((await poll(claim)).json, { state: 'paired', robot_id: robotId });++ });++++ await check('without the Live box the stream stays off Live', async () => {++ const c = (await start({ hostname: 'shy', hardware: {} })).json;++ assert.strictEqual((await post(`/link/${c.id}/approve`, alex, 'name=Shy&profile_id=relay.generic&access_policy=private')).status, 201);++ const p = (await poll(c)).json;++ assert.strictEqual(p.driver, 'none');++ assert.strictEqual((await t.call('POST', '/api/v1/pair', { token: null, body: { robot: p.robot_id, code: p.pairing.code } })).status, 201);++ assert.strictEqual(streamOf(p.robot_id).mirror_to_live, false);++ });++++ await check('denying: the device is told no and nothing is made', async () => {++ const c = (await start({ hostname: 'stranger' })).json;++ assert.strictEqual((await post(`/link/${c.id}/deny`, bob, '', { Origin: 'https://evil.test' })).status, 403);++ const r = await post(`/link/${c.id}/deny`, bob, '');++ assert.strictEqual(r.status, 200);++ assert.ok((await r.text()).includes('That device was told no'));++ assert.deepStrictEqual((await poll(c)).json, { state: 'denied' });++ assert.strictEqual((await post(`/link/${c.id}/approve`, bob, 'name=x&profile_id=adeept.adr036')).status, 404);++ assert.strictEqual((await t.domain.robots.list(bob.subject)).length, 0);++ });++++ await check('a code lasts 15 minutes', async () => {++ const c = (await start({ hostname: 'late' })).json;++ t.clock.offset += 16 * 60 * 1000;++ try {++ assert.strictEqual((await poll(c)).json.state, 'expired');++ assert.strictEqual((await get(`/link?code=${c.user_code}`, alex)).status, 404);++ } finally { t.clock.offset -= 16 * 60 * 1000; }++ });++++ await check('starting a claim is counted by address', async () => {++ let last;++ for (let i = 0; i < 8 && (!last || last.status !== 429); i++) last = await start({ hostname: `flood${i}` });++ assert.strictEqual(last.status, 429);++ });++++ await t.close();++ done();++})();
@@ -41,6 +41,7 @@ Sitemap entries use the release commit time for these code-backed pages.
@@ -41,6 +41,7 @@ Sitemap entries use the release commit time for these code-backed pages.
## Devices
## Devices
- A device (the machine that serves a robot) pairs with one pasted command, shown on the robot's pairing page: `curl -fsSL <BOT_INSTALLER_URL> | sh -s -- --robot rob_… --code XXXX-XXXX [--driver …]` (the Network form in `docs/protocol.md` §1). The `--driver` follows the robot's profile: `adeept` for `adeept.adr036`, `adeept-mecanum` for `adeept.adr036.mecanum`, `cozmo` for `cozmo`, `looi` for `looi` (the community's Generic LOOI driver, a Bluetooth bridge; OpenVibe.Node `docs/hardware-looi.md`); `ros2` for `ros2.diffdrive` and `ros2-holonomic` for `ros2.holonomic` (any ROS 2 robot on `cmd_vel`, through rosbridge or rclpy; OpenVibe.Node `plugins/ros2`); any other profile gets none (`server/domain/index.js` `driverForProfile`), the installer's dry-run `none`.
- A device (the machine that serves a robot) pairs with one pasted command, shown on the robot's pairing page: `curl -fsSL <BOT_INSTALLER_URL> | sh -s -- --robot rob_… --code XXXX-XXXX [--driver …]` (the Network form in `docs/protocol.md` §1). The `--driver` follows the robot's profile: `adeept` for `adeept.adr036`, `adeept-mecanum` for `adeept.adr036.mecanum`, `cozmo` for `cozmo`, `looi` for `looi` (the community's Generic LOOI driver, a Bluetooth bridge; OpenVibe.Node `docs/hardware-looi.md`); `ros2` for `ros2.diffdrive` and `ros2-holonomic` for `ros2.holonomic` (any ROS 2 robot on `cmd_vel`, through rosbridge or rclpy; OpenVibe.Node `plugins/ros2`); any other profile gets none (`server/domain/index.js` `driverForProfile`), the installer's dry-run `none`.
+- **The one command on the robot** (`server/claims.js`, migration 0019): `curl -fsSL <BOT_INSTALLER_URL> | sh` with no robot and no code. The installer finds the kit on the machine (OpenVibe.Node `docs/install.md`) and starts a claim, `POST /api/v1/claims` (anyone, counted by address: a code like `KQ7M-2XRD`, a poll secret, 15 minutes). The owner enters the code at `/link` (signed in; the QR opens `/link?code=…`), sees what the machine found (shown escaped, it only pre-selects the model) and approves it with a name, a model the installer has a driver for (or the generic relay), who may drive, and **Show its camera on my OpenVibe.Live channel**, or denies it. Approving makes the robot exactly as Add robot does and keeps its pairing on the claim; the machine reads it with `GET /api/v1/claims/:id` and its `X-OV-Claim-Secret`, then pairs the usual way. Once it has paired (`domain.pairing.onPaired`) the pairing is dropped and the Live mirror is turned on if the owner asked. The approval page links to the owner's channel with `?bind_robot=rob_…`, where Live asks them to let the channel's chat drive it.
- **A person's own machine that runs their jobs** (plan T14/T16) serves no robot. `openvibe.bot/machines` lists the person's machines (online, the job classes they run, the robot they serve if any), adds one with a one-time command, `curl -fsSL <BOT_INSTALLER_URL> | sh -s -- --network <Network> --pairing pair_… --code XXXX-XXXX --worker` (a Network pairing with ref `worker:<owner>`, so the device keeps `owner_subject` and has no robot; migration 0013), and removes one (revoked here and on Network). Every live machine with a job worker, a robot's included, is reported to Network as a `user-owned` resource offer naming its owner (`server/jobs/offers.js`: one offer per device, `worker:<class>` per class, `up` while connected and `down` otherwise; the whole set as source `bot`, once a minute and two seconds after a device comes, goes or changes what it advertises). Run places a person's job only on that person's machines, and the dispatcher refuses any other (`bot.not_owner`).
- **A person's own machine that runs their jobs** (plan T14/T16) serves no robot. `openvibe.bot/machines` lists the person's machines (online, the job classes they run, the robot they serve if any), adds one with a one-time command, `curl -fsSL <BOT_INSTALLER_URL> | sh -s -- --network <Network> --pairing pair_… --code XXXX-XXXX --worker` (a Network pairing with ref `worker:<owner>`, so the device keeps `owner_subject` and has no robot; migration 0013), and removes one (revoked here and on Network). Every live machine with a job worker, a robot's included, is reported to Network as a `user-owned` resource offer naming its owner (`server/jobs/offers.js`: one offer per device, `worker:<class>` per class, `up` while connected and `down` otherwise; the whole set as source `bot`, once a minute and two seconds after a device comes, goes or changes what it advertises). Run places a person's job only on that person's machines, and the dispatcher refuses any other (`bot.not_owner`).
- **A person's computer for their agents** (plan T14/T17, contracts 0.166.0): a machine installed with `--computer observe|ask|trusted|full` runs OpenVibe.Node's `computer` plugin as the person's own account, and the level set on the machine decides what may happen there (`ask` waits for `openvibe-node approve <id>`). `POST /api/v1/machines/:id/commands` sends `exec`, `fs` and `screen` (`bot.command@1`, the value checked against the contract) to it as its owner, or as their agent (`svc:actor` with `bot.computer.control`, acting as the owner) once the owner turned "Let my agent use this computer" on at `/machines` (migration 0014, `devices.agent_computer`). The answer comes back in `data` (the device socket takes frames up to 640 KB so it fits; a repeated id answers its result without the data). A machine whose plugin is off answers `bot.computer_off`. The audit keeps the program name and argument count, or the file op and path, never stdin, arguments or content. The machines page shows each machine's level, what waits for approval there, and the switch. OpenVibe.Watch (`svc:watch`, `bot.computer.control`, contracts 0.167.0) may send only `probe`: one GET or TCP connect from the machine to its own network, for a watch's `node` source. It works only where the owner turned on "Let my watches check things from this computer" (migration 0015, `devices.watch_probe`), and the audit keeps the probe's host, not its path. Every device's config lists the computer kinds, which only this route sends (a robot's operators never can).
- **A person's computer for their agents** (plan T14/T17, contracts 0.166.0): a machine installed with `--computer observe|ask|trusted|full` runs OpenVibe.Node's `computer` plugin as the person's own account, and the level set on the machine decides what may happen there (`ask` waits for `openvibe-node approve <id>`). `POST /api/v1/machines/:id/commands` sends `exec`, `fs` and `screen` (`bot.command@1`, the value checked against the contract) to it as its owner, or as their agent (`svc:actor` with `bot.computer.control`, acting as the owner) once the owner turned "Let my agent use this computer" on at `/machines` (migration 0014, `devices.agent_computer`). The answer comes back in `data` (the device socket takes frames up to 640 KB so it fits; a repeated id answers its result without the data). A machine whose plugin is off answers `bot.computer_off`. The audit keeps the program name and argument count, or the file op and path, never stdin, arguments or content. The machines page shows each machine's level, what waits for approval there, and the switch. OpenVibe.Watch (`svc:watch`, `bot.computer.control`, contracts 0.167.0) may send only `probe`: one GET or TCP connect from the machine to its own network, for a watch's `node` source. It works only where the owner turned on "Let my watches check things from this computer" (migration 0015, `devices.watch_probe`), and the audit keeps the probe's host, not its path. Every device's config lists the computer kinds, which only this route sends (a robot's operators never can).
- **The live screen** (plan T14, contracts 0.182.0, OpenVibe.Node v0.12.0): the machine's owner, signed in as themselves, watches its screen at `/machines/:id/screen` or through `POST|GET|DELETE /api/v1/machines/:id/screen` (`server/machine-screen.js`).
- **The live screen** (plan T14, contracts 0.182.0, OpenVibe.Node v0.12.0): the machine's owner, signed in as themselves, watches its screen at `/machines/:id/screen` or through `POST|GET|DELETE /api/v1/machines/:id/screen` (`server/machine-screen.js`).
@@ -61,5 +61,5 @@
@@ -61,5 +61,5 @@
"server-side ONVIF cameras (profile camera.onvif, driver onvif, server/onvif): an in-process device beside the simulator attaches a camera configured with BOT_ONVIF_CAMERAS (its URL plus username_ref/password_ref secret references, never in the profile or in clear), writes a real kind server device row, translates ptz commands to ONVIF ContinuousMove/Stop with the profile deadline as a deadman, and carries the camera's GetStatus readout in telemetry; every request goes only to the configured host, with a timeout and no redirects",
"server-side ONVIF cameras (profile camera.onvif, driver onvif, server/onvif): an in-process device beside the simulator attaches a camera configured with BOT_ONVIF_CAMERAS (its URL plus username_ref/password_ref secret references, never in the profile or in clear), writes a real kind server device row, translates ptz commands to ONVIF ContinuousMove/Stop with the profile deadline as a deadman, and carries the camera's GetStatus readout in telemetry; every request goes only to the configured host, with a timeout and no redirects",
"a person's own machines that run their jobs (/machines: add with a --worker command, list, remove); their worker offers reported to Network as user-owned offers naming the owner; jobs reach a machine only for its owner (plan T14/T16)"
"a person's own machines that run their jobs (/machines: add with a --worker command, list, remove); their worker offers reported to Network as user-owned offers naming the owner; jobs reach a machine only for its owner (plan T14/T16)"
],
],
- "updated": "2026-10-17"
+ "updated": "2026-10-20"
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,26 @@
@@ -0,0 +1,26 @@
+-- phase: expand
+-- A device that asks to be added (the installer run with no pairing code: `curl -fsSL https://openvibe.bot/install | sh`).
+-- It shows a code; a signed-in person enters it on /link, sees what the device found, and approves or denies it. An
+-- approval makes the robot (server/claims.js) and leaves its pairing here for the device, which polls with its secret;
+-- the pairing goes once the device has paired. Rows are short-lived (15 minutes to approve; pruned after a day).
+CREATE TABLE IF NOT EXISTS device_claims (
+ id text PRIMARY KEY, -- clm_…
+ user_code_hash text NOT NULL UNIQUE, -- sha256 of the code the device shows (XXXX-XXXX, normalised)
+ secret_hash text NOT NULL, -- sha256 of the device's poll secret
+ hostname text NOT NULL,
+ hardware jsonb NOT NULL DEFAULT '{}'::jsonb, -- what the installer found: shown (escaped), never acted on
+ suggested_profile text, -- the catalogue profile that detection names, if any
+ address text, -- the address the claim came from, shown to the approver
+ state text NOT NULL DEFAULT 'pending', -- pending, approving, approved, denied, paired
+ owner_subject text, -- who approved or denied it
+ robot_id text, -- the robot the approval made
+ pairing jsonb, -- that robot's pairing for the device, until it pairs
+ want_live boolean NOT NULL DEFAULT false, -- the owner asked for the robot's video on their Live channel
+ live_applied_at timestamptz,
+ created_at timestamptz NOT NULL,
+ expires_at timestamptz NOT NULL,
+ decided_at timestamptz,
+ paired_at timestamptz
+);
+CREATE INDEX IF NOT EXISTS device_claims_robot_idx ON device_claims (robot_id) WHERE robot_id IS NOT NULL;
+CREATE INDEX IF NOT EXISTS device_claims_expires_idx ON device_claims (expires_at);
@@ -29,6 +29,8 @@ const TABLES = [
@@ -29,6 +29,8 @@ const TABLES = [
{ table: 'pairing_codes', subject: 'created_by', file: null, erase: anonymize },
{ table: 'pairing_codes', subject: 'created_by', file: null, erase: anonymize },
{ table: 'robot_operators', subject: 'added_by', file: null, erase: anonymize },
{ table: 'robot_operators', subject: 'added_by', file: null, erase: anonymize },
{ table: 'live_conversions', subject: 'owner_subject', file: null },
{ table: 'live_conversions', subject: 'owner_subject', file: null },
+ // A device's request to be added (server/claims.js): who approved or denied it. Short-lived; erased with the account.
+ { table: 'device_claims', subject: 'owner_subject', file: null },
{ table: 'run_jobs', subject: 'subject', value: (usr) => [usr, `user:${usr}`], file: 'run-jobs.json', columns: ['id', 'class', 'state', 'wall_ms', 'exit_reason', 'created_at', 'finished_at'], erase: { keep: 'usage seconds reported to Billing' } },
{ table: 'run_jobs', subject: 'subject', value: (usr) => [usr, `user:${usr}`], file: 'run-jobs.json', columns: ['id', 'class', 'state', 'wall_ms', 'exit_reason', 'created_at', 'finished_at'], erase: { keep: 'usage seconds reported to Billing' } },
// Community robot profiles: the person's own submissions are exported; on deletion the ones still waiting, refused or
// Community robot profiles: the person's own submissions are exported; on deletion the ones still waiting, refused or
// withdrawn go (extraErase) and an approved one stays in the catalogue, authorless (robots may use it).
// withdrawn go (extraErase) and an approved one stays in the catalogue, authorless (robots may use it).
@@ -31,6 +31,8 @@
@@ -31,6 +31,8 @@
* | POST /robots/:id/streaming | bot.robot.manage | the owner |
* | POST /robots/:id/streaming | bot.robot.manage | the owner |
* | POST /jobs, /jobs/:id/cancel, GET /jobs/:id | bot.job.dispatch | — (services only) |
* | POST /jobs, /jobs/:id/cancel, GET /jobs/:id | bot.job.dispatch | — (services only) |
* | POST /pair | — (one-time code) | the agent (code is the credential) |
* | POST /pair | — (one-time code) | the agent (code is the credential) |
+ * | POST /claims | — (anyone) | a device asking to be added: a code |
+ * | GET /claims/:id | — (X-OV-Claim-Secret) | that device: pending, or its pairing|
*
*
* With BOT_PAIRING_AUTHORITY=network the pairing codes are Network's and POST /pair answers 410 bot.pairing_moved.
* With BOT_PAIRING_AUTHORITY=network the pairing codes are Network's and POST /pair answers 410 bot.pairing_moved.
*/
*/
@@ -64,7 +66,7 @@ const MACHINE_SERVICES = {
@@ -64,7 +66,7 @@ const MACHINE_SERVICES = {
'svc:watch': { kinds: new Set(['probe']), flag: 'watch_probe', off: 'the owner has not let their watches check things from this computer' },
'svc:watch': { kinds: new Set(['probe']), flag: 'watch_probe', off: 'the owner has not let their watches check things from this computer' },
};
};
-function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, submissions = null, routines = null, webhooks = null }) {
+function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, submissions = null, routines = null, webhooks = null, claims = null }) {
const r = express.Router();
const r = express.Router();
r.use(limits.reads('bot.read'));
r.use(limits.reads('bot.read'));
// Control first (bot.resource.control), then the index (bot.resource.read) for everything else under /resources.
// Control first (bot.resource.control), then the index (bot.resource.read) for everything else under /resources.
@@ -551,6 +553,24 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
@@ -551,6 +553,24 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
res.status(201).json({ device_id: result.device.id, credential: result.credential, ...domain.present.video(result), robot_id: json(result.device.robot_ids, [])[0], profile: result.profile });
res.status(201).json({ device_id: result.device.id, credential: result.credential, ...domain.present.video(result), robot_id: json(result.device.robot_ids, [])[0], profile: result.profile });
}));
}));
+ // ── A device asking to be added (server/claims.js): the installer run with no robot and no code ─
+ // Anyone may start one: it only makes a code for a signed-in person to approve on /link. Only the secret the start
+ // answered reads it back (a header of its own, so it is never taken for a token). Both are counted by address.
+ const claimStart = limits('bot.claim', { minute: 6, hour: 40 });
+ const claimPoll = limits('bot.claim.poll', { minute: 40, hour: 1200 });
+ 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 });
+ res.setHeader('Cache-Control', 'no-store');
+ res.status(201).json(out);
+ }));
+ r.get('/claims/:id', claimPoll, wrap(async (req, res) => {
+ needClaims();
+ res.setHeader('Cache-Control', 'no-store');
+ res.json(await claims.poll(req.params.id, String(req.headers['x-ov-claim-secret'] || '')));
+ }));
+
// ── Bootstrap of a Network-paired machine (its node token, once after pairing) ────────────────
// ── Bootstrap of a Network-paired machine (its node token, once after pairing) ────────────────
// Binds the machine to its robot and issues its WHIP publish key; calling again re-issues the key (the
// Binds the machine to its robot and issues its WHIP publish key; calling again re-issues the key (the
// old one stops working) for the same device. POST /pair's answer without a credential.
// old one stops working) for the same device. POST /pair's answer without a credential.
@@ -37,6 +37,7 @@ const { createSimulator } = require('./sim');
@@ -37,6 +37,7 @@ const { createSimulator } = require('./sim');
const { createOnvif } = require('./onvif');
const { createOnvif } = require('./onvif');
const { createApiAuth } = require('./api/auth');
const { createApiAuth } = require('./api/auth');
const { v1Router } = require('./api/v1');
const { v1Router } = require('./api/v1');
+const { createClaims } = require('./claims');
const { createJobs } = require('./jobs');
const { createJobs } = require('./jobs');
const { createActorLimits } = require('./api/actor-limits');
const { createActorLimits } = require('./api/actor-limits');
const { createSessionRoutes, viewerMiddleware } = require('./web/session');
const { createSessionRoutes, viewerMiddleware } = require('./web/session');
@@ -86,6 +87,10 @@ function createApp(opts = {}) {
@@ -86,6 +87,10 @@ function createApp(opts = {}) {
onEvent: (type, robotId) => { if (type === 'bot.robot.online' && routines) routines.onOnline(robotId).catch((e) => log.warn(`[Bot] online routines: ${e.message}`)); } });
onEvent: (type, robotId) => { if (type === 'bot.robot.online' && routines) routines.onOnline(robotId).catch((e) => log.warn(`[Bot] online routines: ${e.message}`)); } });
routines = createRoutines({ db, domain, hub, now, log, tickMs: config.automation.routineTickMs });
routines = createRoutines({ db, domain, hub, now, log, tickMs: config.automation.routineTickMs });
const submissions = createSubmissions({ db, config, now, log });
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 });
+ domain.pairing.onPaired((robotId) => claims.afterPair(robotId));
hub.bindDomain(domain);
hub.bindDomain(domain);
// One jobs service for the device socket and the jobs API, so both see the same stdout rings.
// One jobs service for the device socket and the jobs API, so both see the same stdout rings.
const jobs = createJobs({ db: () => domain.db, hub, usage, log, now });
const jobs = createJobs({ db: () => domain.db, hub, usage, log, now });
@@ -159,7 +164,7 @@ function createApp(opts = {}) {
@@ -159,7 +164,7 @@ function createApp(opts = {}) {
const limits = createActorLimits({ config, valkey, now: opts.limitsNow || now, registry: metrics.registry, log });
const limits = createActorLimits({ config, valkey, now: opts.limitsNow || now, registry: metrics.registry, log });
// A computer command may carry a file to write (≤ 256 KB, base64): its route alone takes a larger body.
// A computer command may carry a file to write (≤ 256 KB, base64): its route alone takes a larger body.
app.use(/^\/api\/v1\/machines\/[^/]+\/commands$/, express.json({ limit: '400kb' }));
app.use(/^\/api\/v1\/machines\/[^/]+\/commands$/, express.json({ limit: '400kb' }));
- app.use('/api/v1', express.json({ limit: '64kb' }), (req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); }, apiAuth.middleware, v1Router({ domain, apiAuth, limits, hub, jobs, config, submissions, routines, webhooks }));
+ app.use('/api/v1', express.json({ limit: '64kb' }), (req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); }, apiAuth.middleware, v1Router({ domain, apiAuth, limits, hub, jobs, config, submissions, routines, webhooks, claims }));
app.use('/auth', createSessionRoutes(config, userAuth, { fetchImpl }));
app.use('/auth', createSessionRoutes(config, userAuth, { fetchImpl }));
// ── The legal pages: Terms, Privacy and DMCA on openvibe.bot itself (openvibe-shared/legal). ──
// ── The legal pages: Terms, Privacy and DMCA on openvibe.bot itself (openvibe-shared/legal). ──
// The document carries its own inline theme and OpenVibe Frame boot, which the strict site-wide CSP
// The document carries its own inline theme and OpenVibe Frame boot, which the strict site-wide CSP
@@ -168,7 +173,7 @@ function createApp(opts = {}) {
@@ -168,7 +173,7 @@ function createApp(opts = {}) {
{ const legal = require('openvibe-shared/legal');
{ const legal = require('openvibe-shared/legal');
app.get(legal.PATHS, (req, res, next) => { res.setHeader('Content-Security-Policy', HOME_CSP); next(); }, legal.handler({ id: 'bot', service: 'bot', host: 'openvibe.bot', name: 'OpenVibe.Bot', profile: 'games' })); }
app.get(legal.PATHS, (req, res, next) => { res.setHeader('Content-Security-Policy', HOME_CSP); next(); }, legal.handler({ id: 'bot', service: 'bot', host: 'openvibe.bot', name: 'OpenVibe.Bot', profile: 'games' })); }
app.use(viewerMiddleware(userAuth));
app.use(viewerMiddleware(userAuth));
- app.use(createWebRoutes(config, { domain, sim, onvif, limits, identity, hub, log, submissions, routines, webhooks, 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, releaseLastmod: release.full().released_at === release.full().booted_at ? undefined : release.full().released_at }));
app.use((req, res) => {
app.use((req, res) => {
if (req.path.startsWith('/api/') || req.path.startsWith('/internal/')) return http.sendProblem(res, 404, 'not_found', { ctx: req.ov });
if (req.path.startsWith('/api/') || req.path.startsWith('/internal/')) return http.sendProblem(res, 404, 'not_found', { ctx: req.ov });
@@ -187,7 +192,7 @@ function createApp(opts = {}) {
@@ -187,7 +192,7 @@ function createApp(opts = {}) {
return res.status(500).type('text/plain').send('Something went wrong\n');
return res.status(500).type('text/plain').send('Something went wrong\n');
});
});
- Object.assign(app.locals, { config, db, valkey, domain, keys, outbox, usage, hub, jobs, offers, sim, onvif, userAuth, metrics, submissions, routines, webhooks });
+ Object.assign(app.locals, { config, db, valkey, domain, keys, outbox, usage, hub, jobs, offers, sim, onvif, userAuth, metrics, submissions, routines, webhooks, claims });
return app;
return app;
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,171 @@
@@ -0,0 +1,171 @@
+'use strict';
+/**
+ * A device that asks to be added: the one command on the machine (plan T15 "five-minute path", owner 2026-10-17: "I
+ * should be able to run a single command on my raspi … ready to go live and controllable").
+ *
+ * curl -fsSL https://openvibe.bot/install | sh (no robot, no code)
+ *
+ * The installer looks at the machine (the kit on its I2C bus, the camera), then starts a claim here and shows the
+ * person a code and openvibe.bot/link. Signed in there, they see what the device found and approve it (name, model,
+ * who may drive, its video on their Live channel) or deny it. The approval makes the robot exactly as Add robot does
+ * (domain.robots.create) and keeps that robot's pairing on the claim. The device polls with the secret only it holds,
+ * gets the pairing and pairs the usual way (POST /pair, or Network's form), so pairing itself is unchanged. When it has
+ * 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, … }
+ * 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 }
+ * deny(id, owner)
+ * afterPair(robotId) → the onPaired hook
+ * prune()
+ *
+ * The code is 8 Crockford characters like a pairing code (only its hash is stored); the secret is 32 random bytes
+ * (hash stored). What the device reports is untrusted: it is cut to known fields and lengths, shown escaped, and only
+ * ever pre-selects a model the person can change. A claim lasts 15 minutes.
+ */
+const { prefixedId, iso, token, hashSecret, secretEquals, fail, text } = require('./util');
+const { newCode, formatCode, normaliseCode, isCodeShape } = require('./domain');
+const { getProfile } = require('./profiles');
+
+const TTL_MS = 15 * 60 * 1000;
+const POLL_S = 3;
+const KEEP_MS = 24 * 3600 * 1000;
+const HARDWARE_FIELDS = { model: 80, os: 80, arch: 20, kit: 60, kit_name: 80, camera: 80 };
+
+const clip = (v, max) => (typeof v === 'string' ? v.replace(/[\u0000-\u001f\u007f]/g, ' ').trim().slice(0, max) : '');
+
+/** The hostname as a robot name: what a hostname may hold, else "robot". */
+function cleanHostname(v) {
+ const s = clip(v, 63).replace(/[^A-Za-z0-9._-]/g, '');
+ return s || 'robot';
+}
+
+/** Only the fields the approval page shows, each cut to length; `found` is a short list ("PCA9685 at 0x5f"). */
+function cleanHardware(h) {
+ const out = {};
+ if (!h || typeof h !== 'object' || Array.isArray(h)) return out;
+ for (const [k, max] of Object.entries(HARDWARE_FIELDS)) { const v = clip(h[k], max); if (v) out[k] = v; }
+ if (Array.isArray(h.found)) {
+ const found = h.found.map((x) => clip(x, 60)).filter(Boolean).slice(0, 8);
+ if (found.length) out.found = found;
+ }
+ return out;
+}
+
+function createClaims({ db, domain, config, now = () => Date.now(), log = console }) {
+ const base = String(config.baseUrl || 'https://openvibe.bot').replace(/\/+$/, '');
+
+ async function start({ hostname, hardware, address = null } = {}) {
+ 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());
+ const secret = token(32);
+ const at = now();
+ // A fresh code until one is free: two live claims never share a code.
+ for (let tries = 0; tries < 5; tries++) {
+ const code = newCode();
+ const row = await db.maybe(
+ `INSERT INTO device_claims (id, user_code_hash, secret_hash, hostname, hardware, suggested_profile, address, created_at, expires_at)
+ VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7, $8, $9) ON CONFLICT (user_code_hash) DO NOTHING RETURNING id`,
+ [id, hashSecret(code), hashSecret(secret), cleanHostname(hostname), JSON.stringify(hw), kit ? kit.profile.id : null,
+ address ? clip(String(address), 64) : null, iso(at), iso(at + TTL_MS)]);
+ if (row) {
+ const userCode = formatCode(code);
+ 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}`,
+ };
+ }
+ }
+ fail(503, 'bot.unavailable', 'could not make a claim code; try again');
+ return null;
+ }
+
+ const live = (row) => row && new Date(row.expires_at).getTime() > now();
+
+ async function poll(id, secret) {
+ const row = typeof id === 'string' && /^clm_[0-9A-Z]{26}$/.test(id) ? await db.maybe('SELECT * FROM device_claims WHERE id = $1', [id]) : null;
+ // A wrong secret and a claim that does not exist answer the same.
+ if (!row || typeof secret !== 'string' || !secretEquals(hashSecret(secret), row.secret_hash)) fail(404, 'bot.claim_not_found', 'no such claim');
+ if (row.state === 'denied') return { state: 'denied' };
+ if (row.state === 'paired') return { state: 'paired', robot_id: row.robot_id };
+ if (row.state === 'approved' && row.pairing) {
+ const robot = await domain.robots.get(row.robot_id);
+ const p = row.pairing;
+ return {
+ state: 'approved', robot_id: row.robot_id, name: robot ? robot.name : null, profile_id: robot ? robot.profile_id : null,
+ driver: robot ? domain.pairing.driverForProfile(robot.profile_id) : 'none',
+ pairing: p.pairing_id ? { network: config.network && config.network.url, pairing_id: p.pairing_id, code: p.code, expires_at: p.expires_at }
+ : { robot: row.robot_id, code: p.code, expires_at: p.expires_at },
+ panel_url: `${base}/panel/${row.robot_id}`, live: !!row.want_live,
+ };
+ }
+ if (!live(row)) return { state: 'expired' };
+ return { state: 'pending', expires_at: new Date(row.expires_at).toISOString(), interval: POLL_S };
+ }
+
+ async function find(userCode) {
+ const normal = normaliseCode(userCode);
+ if (!isCodeShape(normal)) return null;
+ const row = await db.maybe("SELECT * FROM device_claims WHERE user_code_hash = $1 AND state = 'pending'", [hashSecret(normal)]);
+ return live(row) ? row : null;
+ }
+
+ async function pending(id) {
+ const row = typeof id === 'string' ? await db.maybe('SELECT * FROM device_claims WHERE id = $1', [id]) : null;
+ if (!row || row.state !== 'pending' || !live(row)) fail(404, 'bot.claim_not_found', 'that device is no longer waiting (a code lasts 15 minutes): run the command on it again');
+ return row;
+ }
+
+ async function approve(id, owner, { name, profile_id, access_policy = 'private', live: wantLive = false } = {}) {
+ await pending(id);
+ // Taken first, so two approvals at once cannot make two robots; given back if making the robot fails.
+ const taken = await db.maybe("UPDATE device_claims SET state = 'approving', owner_subject = $2 WHERE id = $1 AND state = 'pending' RETURNING *", [id, owner]);
+ if (!taken) fail(409, 'bot.claim_taken', 'that device was just approved or denied');
+ let made;
+ try {
+ made = await domain.robots.create({ owner, name: text(name, 'name', 80) || taken.hostname, profile_id, access_policy });
+ } catch (e) {
+ await db.query("UPDATE device_claims SET state = 'pending', owner_subject = NULL WHERE id = $1 AND state = 'approving'", [id]);
+ throw e;
+ }
+ const p = made.pairing || {};
+ const claim = await db.maybe(
+ `UPDATE device_claims SET state = 'approved', robot_id = $2, pairing = $3::jsonb, want_live = $4, decided_at = $5
+ WHERE id = $1 RETURNING *`,
+ [id, made.robot.id, JSON.stringify({ code: p.code, expires_at: p.expires_at, pairing_id: p.pairing_id || null }), !!wantLive, iso(now())]);
+ return { robot: made.robot, claim };
+ }
+
+ 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())]);
+ if (!row) fail(409, 'bot.claim_taken', 'that device was just approved or denied');
+ }
+
+ /** The robot paired: drop the pairing, and turn on what its owner asked for when they approved it. */
+ async function afterPair(robotId) {
+ 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())]);
+ if (!row || !row.want_live || row.live_applied_at) return;
+ try {
+ await domain.streaming.set(robotId, { subject: row.owner_subject, kind: 'user' }, { to: 'live', on: true });
+ await db.query('UPDATE device_claims SET live_applied_at = $2 WHERE id = $1', [row.id, iso(now())]);
+ } catch (e) {
+ log.warn(`[Bot] claim ${row.id}: could not put ${robotId} on Live: ${e.message}`);
+ }
+ }
+
+ async function prune() {
+ await db.query("DELETE FROM device_claims WHERE expires_at < $1 AND state <> 'approved'", [iso(now() - KEEP_MS)]);
+ // An approval whose device never paired keeps its pairing until the pairing itself is long dead.
+ await db.query("DELETE FROM device_claims WHERE state = 'approved' AND decided_at < $1", [iso(now() - KEEP_MS)]);
+ }
+
+ return { start, poll, find, waiting: pending, approve, deny, afterPair, prune, TTL_MS };
+}
+
+module.exports = { createClaims, cleanHardware, cleanHostname, CLAIM_TTL_MS: TTL_MS };
@@ -298,6 +298,15 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -298,6 +298,15 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
}
}
// ── Pairing (ADR-043 decision 2) ──────────────────────────────────────────────────────────────
// ── Pairing (ADR-043 decision 2) ──────────────────────────────────────────────────────────────
+ // What runs once a robot's device has paired (its stream exists from then on): a device claim's choices
+ // (server/claims.js). A hook that fails is logged; the pairing stands.
+ const pairedHooks = [];
+ const onPaired = (fn) => { pairedHooks.push(fn); };
+ async function paired(robotId) {
+ for (const fn of pairedHooks) {
+ try { await fn(robotId); } catch (e) { log.warn(`[Bot] after pairing ${robotId}: ${e.message}`); }
+ }
+ }
const networkPairing = () => config.pairing.authority === 'network';
const networkPairing = () => config.pairing.authority === 'network';
async function createPairingCode(robotId, createdBy, installerUrl, robotRow = null) {
async function createPairingCode(robotId, createdBy, installerUrl, robotRow = null) {
const robot = robotRow || await getRobot(robotId);
const robot = robotRow || await getRobot(robotId);
@@ -383,6 +392,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -383,6 +392,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
return { device, credential, issued, robot_id: row.robot_id };
return { device, credential, issued, robot_id: row.robot_id };
});
});
if (result.error) fail(result.error[0], result.error[1], result.error[2]);
if (result.error) fail(result.error[0], result.error[1], result.error[2]);
+ await paired(result.robot_id);
const profile = await getProfile(db, (await getRobot(result.robot_id)).profile_id);
const profile = await getProfile(db, (await getRobot(result.robot_id)).profile_id);
return { device: result.device, credential: result.credential, ...videoKey(result.issued), profile: profile ? profile.profile : null };
return { device: result.device, credential: result.credential, ...videoKey(result.issued), profile: profile ? profile.profile : null };
}
}
@@ -767,6 +777,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -767,6 +777,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
[id, !!issued, issued ? issued.hint : null, iso(now())]);
[id, !!issued, issued ? issued.hint : null, iso(now())]);
});
});
if (!device) fail(404, 'bot.device_not_found', 'no live device');
if (!device) fail(404, 'bot.device_not_found', 'no live device');
+ if (robot) await paired(robot.id); // a Network-paired machine's first key (and any later one)
const profile = robot ? await getProfile(db, robot.profile_id) : null;
const profile = robot ? await getProfile(db, robot.profile_id) : null;
return { device, ...videoKey(issued), profile: profile ? profile.profile : null };
return { device, ...videoKey(issued), profile: profile ? profile.profile : null };
}
}
@@ -1159,7 +1170,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -1159,7 +1170,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
robots: { create: createRobot, list: listRobots, get: getRobot, update: updateRobot, setEmbedPublic, setListed, listListed, setAgentControl, remove: removeRobot,
robots: { create: createRobot, list: listRobots, get: getRobot, update: updateRobot, setEmbedPublic, setListed, listListed, setAgentControl, remove: removeRobot,
localProfile, saveLocalProfile, useCatalogueProfile },
localProfile, saveLocalProfile, useCatalogueProfile },
members: { roleOf, add: addOperator, remove: removeOperator, list: listOperators },
members: { roleOf, add: addOperator, remove: removeOperator, list: listOperators },
- pairing: { create: createPairingCode, redeem, prune: prunePairingCodes, installerCommand, driverForProfile, whipUrl },
+ pairing: { create: createPairingCode, redeem, prune: prunePairingCodes, installerCommand, driverForProfile, whipUrl, onPaired },
devices: { byCredential, bindNode, issuePublishKey, revokeVideo, updateDeclared, revokeNode, ensureServerDevice, get: getDevice, listForRobot: listDevicesForRobot, rotate: rotateDevice, revoke: revokeDevice, revokeByNode, touchSeen, setOnline, ownerOf, onChange },
devices: { byCredential, bindNode, issuePublishKey, revokeVideo, updateDeclared, revokeNode, ensureServerDevice, get: getDevice, listForRobot: listDevicesForRobot, rotate: rotateDevice, revoke: revokeDevice, revokeByNode, touchSeen, setOnline, ownerOf, onChange },
machines: { mint: mintMachine, list: listMachines, workerRef, setAgentComputer, setWatchProbe,
machines: { mint: mintMachine, list: listMachines, workerRef, setAgentComputer, setWatchProbe,
screen: { key: screenKey, live: screenLive, end: screenEnd, archive: screenArchive } },
screen: { key: screenKey, live: screenLive, end: screenEnd, archive: screenArchive } },
@@ -34,7 +34,7 @@ async function main() {
@@ -34,7 +34,7 @@ async function main() {
const valkey = createValkey({ url: config.valkey.url, prefix: config.valkey.prefix });
const valkey = createValkey({ url: config.valkey.url, prefix: config.valkey.prefix });
const app = createApp({ config, db, valkey, registry });
const app = createApp({ config, db, valkey, registry });
- const { domain, keys, outbox, usage, hub, offers, sim, onvif, routines, webhooks } = app.locals;
+ const { domain, keys, outbox, usage, hub, offers, sim, onvif, routines, webhooks, claims } = app.locals;
keys.start().catch(() => { /* logged by the JWKS client */ });
keys.start().catch(() => { /* logged by the JWKS client */ });
const timers = [];
const timers = [];
@@ -42,6 +42,7 @@ async function main() {
@@ -42,6 +42,7 @@ async function main() {
const every = (ms, fn) => { const t = setInterval(() => { Promise.resolve().then(fn).catch((e) => console.warn('[Bot] job:', e.message)); }, ms); t.unref(); timers.push(t); };
const every = (ms, fn) => { const t = setInterval(() => { Promise.resolve().then(fn).catch((e) => console.warn('[Bot] job:', e.message)); }, ms); t.unref(); timers.push(t); };
every(6 * 3600 * 1000, () => domain.audit.prune(30));
every(6 * 3600 * 1000, () => domain.audit.prune(30));
every(30 * 60 * 1000, () => domain.pairing.prune());
every(30 * 60 * 1000, () => domain.pairing.prune());
+ every(30 * 60 * 1000, () => claims.prune()); // device claims a day past their time (server/claims.js)
every(1000, async () => { for (const robotId of await domain.queue.sweep()) hub.broadcast(robotId); });
every(1000, async () => { for (const robotId of await domain.queue.sweep()) hub.broadcast(robotId); });
outbox.start();
outbox.start();
usage.start();
usage.start();
@@ -235,6 +235,8 @@ function renderAddRobot({ config, models = [], values = {}, error = null }) {
@@ -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);
.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>
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>
<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" 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">
<form class="dir-form" method="post" action="/robots">
${error ? `<p class="dir-error" role="alert">${esc(error)}</p>` : ''}
${error ? `<p class="dir-error" role="alert">${esc(error)}</p>` : ''}
<label class="dir-field"><span class="dir-field-label">Name</span><input type="text" name="name" maxlength="80" required autocomplete="off" placeholder="e.g. Kitchen rover" value="${esc(values.name || '')}"></label>
<label class="dir-field"><span class="dir-field-label">Name</span><input type="text" name="name" maxlength="80" required autocomplete="off" placeholder="e.g. Kitchen rover" value="${esc(values.name || '')}"></label>
@@ -15,6 +15,8 @@
@@ -15,6 +15,8 @@
* renderWidget(widget, { profile, allowed_commands })
* renderWidget(widget, { profile, allowed_commands })
* renderRobotsPage({ robots, profiles, error, values })
* renderRobotsPage({ robots, profiles, error, values })
* renderPairingPage({ robot, pairing, profile })
* renderPairingPage({ robot, pairing, profile })
+ * renderLinkPage({ code, error, installer }), renderClaimPage({ claim, models, values, error }),
+ * renderClaimApproved({ robot, profile, live, chatUrl }) /link: a device that asked to be added (server/claims.js)
* camerasOf(profile)
* camerasOf(profile)
* esc(value)
* esc(value)
*/
*/
@@ -578,6 +580,76 @@ ${first}
@@ -578,6 +580,76 @@ ${first}
return page(`Pair ${robot.name}`, body, { scripts: ['/panel/panel.js'] });
return page(`Pair ${robot.name}`, body, { scripts: ['/panel/panel.js'] });
}
}
+/**
+ * /link (server/claims.js): the code a device shows after `curl -fsSL https://openvibe.bot/install | sh` with no code.
+ * renderLinkPage asks for it; renderClaimPage shows what that device found and the choices its robot starts with;
+ * renderClaimApproved follows the device as it pairs on its own.
+ */
+function renderLinkPage({ code = '', error = null, installer = 'curl -fsSL https://openvibe.bot/install | sh' } = {}) {
+ const body = `${topbar('<span class="crumb">Add a device</span>')}
+<main class="page link">
+<h1>Add a device</h1>
+<form class="card" method="get" action="/link">
+<p>Enter the code the robot's computer shows. It shows one when you run this on it:</p>
+<pre tabindex="0" class="installer"><code>${esc(installer)}</code></pre>
+${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>
+</main>`;
+ return page('Add a device', body);
+}
+
+function renderClaimPage({ claim, models = [], values = {}, error = null, now = Date.now() }) {
+ const hw = claim.hardware || {};
+ const minutes = (ms) => Math.max(0, Math.round(ms / 60000));
+ const asked = minutes(now - new Date(claim.created_at).getTime());
+ const left = minutes(new Date(claim.expires_at).getTime() - now);
+ const suggested = models.some((m) => m.id === claim.suggested_profile) ? claim.suggested_profile : null;
+ const chosen = values.profile_id || suggested || (models[0] && models[0].id);
+ const facts = [
+ ['Computer', [hw.model, hw.os, hw.arch].filter(Boolean).join(' · ') || 'not reported'],
+ ['It found', (hw.found && hw.found.length ? hw.found.join(', ') : '') || hw.kit_name || 'no kit it knows'],
+ ['Camera', hw.camera || 'none found'],
+ ['Asked', `${asked < 1 ? 'just now' : `${asked} min ago`}${claim.address ? ` from ${claim.address}` : ''}`],
+ ];
+ const option = (m) => `<option value="${esc(m.id)}"${m.id === chosen ? ' selected' : ''}>${esc(m.name)}${m.id === suggested ? ' (found on it)' : ''}</option>`;
+ const policy = values.access_policy || 'private';
+ const live = values.live !== false;
+ const body = `${topbar('<span class="crumb">Add a device</span>')}
+<main class="page link">
+<h1>Add ${esc(claim.hostname)}?</h1>
+<p class="muted">Only approve a device you set up yourself, just now. If you did not run the command on it, deny it.</p>
+<dl class="card facts">${facts.map(([k, v]) => `<dt>${esc(k)}</dt><dd>${esc(v)}</dd>`).join('')}</dl>
+<form class="card" method="post" action="/link/${esc(claim.id)}/approve">
+${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" value="${esc(values.name != null ? values.name : claim.hostname)}"></label>
+<label class="field"><span class="field-label">Model</span><select name="profile_id">${models.map(option).join('')}</select></label>
+<label class="field"><span class="field-label">Who may drive</span><select name="access_policy">${POLICIES.map(([v, label]) => `<option value="${esc(v)}"${v === policy ? ' selected' : ''}>${esc(label)}</option>`).join('')}</select></label>
+<label class="field check"><input type="checkbox" name="live" value="on"${live ? ' checked' : ''}> <span>Show its camera on my OpenVibe.Live channel</span></label>
+<button type="submit" class="primary">Approve</button>
+</form>
+<form method="post" action="/link/${esc(claim.id)}/deny"><button type="submit" class="quiet">Deny</button> <span class="muted">This code works for ${left} more minute${left === 1 ? '' : 's'}.</span></form>
+</main>`;
+ return page(`Add ${claim.hostname}`, body);
+}
+
+function renderClaimApproved({ robot, profile = null, live = false, chatUrl = null }) {
+ const body = `${topbar(`<span class="crumb">${esc(robot.name)}</span>`)}
+<main class="page pairing" data-pair-robot="${esc(robot.id)}">
+<h1>${esc(robot.name)} is yours</h1>
+<p class="pair-status" data-pair-status data-state="waiting" role="status" aria-live="polite"><span class="dot"></span><span data-pair-text>Waiting for the device.</span> <a class="button primary" href="/panel/${esc(robot.id)}" data-pair-open hidden>Open the panel</a></p>
+<ol class="steps">
+<li class="card"><h2>The device carries on by itself${profile ? ` <span class="pill">${esc(profile.name)}</span>` : ''}</h2>
+<p class="muted">It pairs, starts its driver and its camera, and prints its panel link. This page turns green when it connects.</p></li>
+${live ? '<li class="card"><h2>On your Live channel</h2><p class="muted">Its camera goes on your OpenVibe.Live channel once it is streaming. Turn that off any time on its panel.</p></li>' : ''}
+${chatUrl ? `<li class="card"><h2>Drive it from your chat</h2><p class="muted">Let your channel's chat commands (!forward, !left, !say …) drive this robot.</p><p><a class="button" href="${esc(chatUrl)}">Use it in my OpenVibe.Live chat</a></p></li>` : ''}
+</ol>
+<p class="muted"><a href="/panel/${esc(robot.id)}">Open the panel</a> any time.</p>
+</main>`;
+ return page(`${robot.name} is yours`, body, { scripts: ['/panel/panel.js'] });
+}
+
/**
/**
* /machines (plan T14/T16): a person's own machines that run jobs for them, and the command that adds one. A machine
* /machines (plan T14/T16): a person's own machines that run jobs for them, and the command that adds one. A machine
* here runs its owner's jobs only (Bot refuses anyone else's), connects out, and keeps its local limits and kill switch.
* here runs its owner's jobs only (Bot refuses anyone else's), connects out, and keeps its local limits and kill switch.
@@ -709,4 +781,4 @@ function renderPhonePage() {
@@ -709,4 +781,4 @@ function renderPhonePage() {
return page('This phone is a robot', body, { scripts: ['/panel/phone-device.js'], bodyClass: 'phone-page', description: 'Turn a phone into a robot on OpenVibe.Bot from its browser: its camera, voice and face, with nothing to install.', canonical: '/device/phone' });
return page('This phone is a robot', body, { scripts: ['/panel/phone-device.js'], bodyClass: 'phone-page', description: 'Turn a phone into a robot on OpenVibe.Bot from its browser: its camera, voice and face, with nothing to install.', canonical: '/device/phone' });
}
}
-module.exports = { page, renderPanel, renderPhonePage, renderMachinesPage, renderMachineScreenPage, panelScripts, isSim, TRY_DESCRIPTION, renderEmbedRefused, renderDrivePage, renderWidget, renderRobotsPage, renderPairingPage, camerasOf, esc };
+module.exports = { page, renderPanel, renderPhonePage, renderMachinesPage, renderMachineScreenPage, panelScripts, isSim, TRY_DESCRIPTION, renderEmbedRefused, renderDrivePage, renderWidget, renderRobotsPage, renderPairingPage, renderLinkPage, renderClaimPage, renderClaimApproved, camerasOf, esc };
@@ -36,7 +36,7 @@ const { getProfile, listProfiles, loadProfiles } = require('../profiles');
@@ -36,7 +36,7 @@ const { getProfile, listProfiles, loadProfiles } = require('../profiles');
const { frameAncestors } = require('../config');
const { frameAncestors } = require('../config');
const { BotError } = require('../util');
const { BotError } = require('../util');
const kits = require('../kits');
const kits = require('../kits');
-const { renderPanel, renderEmbedRefused, renderDrivePage, renderPairingPage, renderPhonePage, renderMachinesPage, renderMachineScreenPage } = require('./render');
+const { renderPanel, renderEmbedRefused, renderDrivePage, renderPairingPage, renderPhonePage, renderMachinesPage, renderMachineScreenPage, renderLinkPage, renderClaimPage, renderClaimApproved } = require('./render');
const { createMachineScreen } = require('../machine-screen');
const { createMachineScreen } = require('../machine-screen');
const { renderDocsIndex, renderDriversPage, renderProfilesPage, renderAutomationPage, renderComputersPage } = require('./docs');
const { renderDocsIndex, renderDriversPage, renderProfilesPage, renderAutomationPage, renderComputersPage } = require('./docs');
const { checkProfile } = require('../profiles');
const { checkProfile } = require('../profiles');
@@ -140,11 +140,11 @@ const PANEL_CSP = ["default-src 'self'", `script-src 'self' ${PANEL_SCRIPT_SRC}`
@@ -140,11 +140,11 @@ const PANEL_CSP = ["default-src 'self'", `script-src 'self' ${PANEL_SCRIPT_SRC}`
const embedCsp = (config) => ["default-src 'self'", `script-src 'self' ${PANEL_SCRIPT_SRC}`, `connect-src 'self' ${PANEL_CONNECT_SRC}`,
const embedCsp = (config) => ["default-src 'self'", `script-src 'self' ${PANEL_SCRIPT_SRC}`, `connect-src 'self' ${PANEL_CONNECT_SRC}`,
`frame-ancestors ${frameAncestors(config)}`, "object-src 'none'", "base-uri 'self'"].join('; ');
`frame-ancestors ${frameAncestors(config)}`, "object-src 'none'", "base-uri 'self'"].join('; ');
-function createWebRoutes(config, { domain = null, sim = null, onvif = null, limits = null, identity = null, hub = null, log = console, submissions = null, routines = null, webhooks = 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, releaseLastmod } = {}) {
const r = express.Router();
const r = express.Router();
// The public front page is crawlable; sign-in, the API and every signed-in or per-robot page are not.
// The public front page is crawlable; sign-in, the API and every signed-in or per-robot page are not.
r.get('/robots.txt', (req, res) => res.type('text/plain').send(['User-agent: *', 'Allow: /$', 'Disallow: /auth/', 'Disallow: /api/',
r.get('/robots.txt', (req, res) => res.type('text/plain').send(['User-agent: *', 'Allow: /$', 'Disallow: /auth/', 'Disallow: /api/',
- 'Disallow: /robots/new', 'Disallow: /robots/submit', 'Disallow: /robots/submissions', 'Disallow: /pair/', 'Disallow: /panel/', 'Disallow: /install', `Sitemap: ${config.baseUrl}/sitemap.xml`, ''].join('\n')));
+ 'Disallow: /robots/new', 'Disallow: /robots/submit', 'Disallow: /robots/submissions', 'Disallow: /pair/', 'Disallow: /panel/', 'Disallow: /install', '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.
// `curl -fsSL` follows the redirect. The target is config only (checked at boot): no query parameter steers it.
r.get('/install', (req, res) => res.redirect(302, config.installer.sourceUrl));
r.get('/install', (req, res) => res.redirect(302, config.installer.sourceUrl));
// The sitemap lists every public GET page (openvibe-shared/seo); robots.txt already names it.
// The sitemap lists every public GET page (openvibe-shared/seo); robots.txt already names it.
@@ -433,6 +433,62 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
@@ -433,6 +433,62 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
return res.status(201).type('html').send(renderPairingPage({ robot: domain.present.robot(robot), pairing, profile, phoneUrl: phoneUrl(robot.id, pairing) }));
return res.status(201).type('html').send(renderPairingPage({ robot: domain.present.robot(robot), pairing, profile, phoneUrl: phoneUrl(robot.id, pairing) }));
}));
}));
+ // ── A device that asked to be added (server/claims.js): its code, what it found, approve or deny ──
+ // Looking a code up is counted per person like Add robot, so codes cannot be guessed from here either.
+ const lookup = limits
+ ? [(req, res, next) => { if (req.viewer && !req.principal) req.principal = req.viewer; next(); }, limits('bot.claim.lookup', { minute: 20, hour: 200 })]
+ : [];
+ // What a claimed device can be: a model the installer has a driver for, or the generic relay (your own program).
+ const claimModels = async () => (await catalogue())
+ .filter((m) => !m.id.startsWith('local.') && (domain.pairing.driverForProfile(m.id) !== 'none' || m.id === 'relay.generic'));
+ const linkInstaller = () => `curl -fsSL ${config.installer.scriptUrl} | sh`;
+ const needClaims = () => { if (!claims) throw new BotError(404, 'bot.not_found', 'adding a device by code is not available here'); };
+ r.get('/link', ...lookup, page(async (req, res) => {
+ requireUser(req);
+ needClaims();
+ const code = typeof req.query.code === 'string' ? req.query.code.slice(0, 20) : '';
+ if (!code) return res.type('html').send(renderLinkPage({ installer: linkInstaller() }));
+ const claim = await claims.find(code);
+ if (!claim) {
+ return res.status(404).type('html').send(renderLinkPage({ code, installer: linkInstaller(),
+ error: 'No device is waiting with that code. A code lasts 15 minutes: run the command on the device again for a new one.' }));
+ }
+ return res.type('html').send(renderClaimPage({ claim, models: await claimModels() }));
+ }));
+ r.post('/link/:id/approve', ...manage, express.urlencoded({ extended: false, limit: '4kb' }), page(async (req, res) => {
+ const me = requireUser(req);
+ needClaims();
+ if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');
+ const body = req.body || {};
+ const values = { name: typeof body.name === 'string' ? body.name : '', profile_id: String(body.profile_id || ''),
+ access_policy: String(body.access_policy || 'private'), live: body.live === 'on' };
+ const models = await claimModels();
+ let made;
+ try {
+ if (!models.some((m) => m.id === values.profile_id)) throw new BotError(422, 'bot.unknown_profile', 'choose one of the models listed');
+ made = await claims.approve(req.params.id, me.subject, values);
+ } catch (e) {
+ if (e.status === 422) {
+ const claim = await claims.waiting(req.params.id);
+ return res.status(422).type('html').send(renderClaimPage({ claim, models, values, error: e.detail || e.code }));
+ }
+ throw e;
+ }
+ const profile = await profileOf(made.robot);
+ // Their Live channel (the username is the same on every site) binds the robot to its chat after they confirm there.
+ const liveOrigin = (config.embed && config.embed.origins && config.embed.origins[0]) || null;
+ const username = req.viewerClaims && typeof req.viewerClaims.username === 'string' ? req.viewerClaims.username : '';
+ const chatUrl = liveOrigin && username ? `${liveOrigin}/@${encodeURIComponent(username)}?bind_robot=${encodeURIComponent(made.robot.id)}` : null;
+ return res.status(201).type('html').send(renderClaimApproved({ robot: domain.present.robot(made.robot), profile, live: values.live, chatUrl }));
+ }));
+ r.post('/link/:id/deny', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {
+ const me = requireUser(req);
+ needClaims();
+ if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');
+ await claims.deny(req.params.id, me.subject);
+ return res.type('html').send(renderLinkPage({ installer: linkInstaller(), error: 'Denied. That device was told no and added nothing.' }));
+ }));
+
// A load replaces the robot's unused code, so only a navigation from this site (or a typed address) mints one.
// 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 crossSiteOrPrefetch = (req) => {
const site = req.headers['sec-fetch-site'];
const site = req.headers['sec-fetch-site'];
new file mode 100644
new file mode 100644
@@ -0,0 +1,150 @@
@@ -0,0 +1,150 @@
+'use strict';
+// A device that asks to be added (server/claims.js): the installer run with no code starts a claim, the owner approves
+// it on /link, the device collects the pairing of the robot that made and pairs the usual way, and what the owner chose
+// (its video on their Live channel) is turned on once it has paired. Owner, 2026-10-17: "I should be able to run a
+// single command on my raspi … ready to go live and controllable".
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/app');
+
+(async () => {
+ const t = await boot();
+ const alex = t.network.newUser('alex');
+ const bob = t.network.newUser('bob');
+ const cookie = (user) => ({ Cookie: `ov_token=${t.network.signUser(user)}` });
+ const get = (p, user) => fetch(t.base + p, { redirect: 'manual', headers: user ? cookie(user) : {} });
+ const post = (p, user, body, headers = {}) => fetch(t.base + p, {
+ method: 'POST', redirect: 'manual', body,
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...(user ? cookie(user) : {}), ...headers },
+ });
+ const start = (body) => t.call('POST', '/api/v1/claims', { token: null, body });
+ const poll = (c, secret = c.secret) => t.call('GET', `/api/v1/claims/${c.id}`, { token: null, headers: { 'X-OV-Claim-Secret': secret } });
+ const streamOf = (robotId) => [...t.openre.streams.values()].find((s) => s.refs.some((r) => r.type === 'robot' && r.id === robotId));
+ // What OpenVibe.Node's installer sends after looking at an Adeept ADR036 on a Pi 4.
+ const pi = {
+ hostname: 'raspi',
+ hardware: { model: 'Raspberry Pi 4 Model B Rev 1.4', os: 'Debian GNU/Linux 12 (bookworm)', arch: 'aarch64', kit: 'adeept.adr036',
+ kit_name: 'Adeept ADR036 4WD', found: ['PCA9685 at 0x5f', 'ADS7830 at 0x48'], camera: 'imx219', password: 'hunter2' },
+ };
+ let claim, robotId;
+
+ await check('a device starts a claim with no token: a code, a secret, and where to enter the code', async () => {
+ const r = await start(pi);
+ assert.strictEqual(r.status, 201, r.text);
+ claim = r.json;
+ assert.match(claim.id, /^clm_[0-9A-Z]{26}$/);
+ assert.match(claim.user_code, /^[0-9A-HJKMNP-TV-Z]{4}-[0-9A-HJKMNP-TV-Z]{4}$/);
+ assert.ok(claim.secret.length >= 40 && claim.interval >= 1);
+ assert.strictEqual(claim.verification_url_complete, `${t.config.baseUrl}/link?code=${claim.user_code}`);
+ const row = await t.db.maybe('SELECT * FROM device_claims WHERE id = $1', [claim.id]);
+ assert.ok(row.user_code_hash !== claim.user_code && row.secret_hash !== claim.secret, 'only hashes are kept');
+ assert.ok(!('password' in row.hardware), 'only the fields the page shows are kept');
+ assert.strictEqual(row.suggested_profile, 'adeept.adr036');
+ });
+
+ await check('polling: the right secret sees pending, a wrong one the same 404 as no claim at all', async () => {
+ assert.strictEqual((await poll(claim)).json.state, 'pending');
+ assert.strictEqual((await poll(claim, 'nope')).status, 404);
+ assert.strictEqual((await poll({ id: 'clm_01JZ00000000000000000000AA', secret: claim.secret })).status, 404);
+ });
+
+ await check('/link: sign in first, then the device, what it found, and its choices', async () => {
+ const out = await get(`/link?code=${claim.user_code}`);
+ assert.strictEqual(out.status, 302);
+ assert.match(out.headers.get('location'), /^\/auth\/login\?next=%2Flink%3Fcode%3D/);
+ const r = await get(`/link?code=${claim.user_code.toLowerCase().replace('-', ' ')}`, alex);
+ assert.strictEqual(r.status, 200);
+ const html = await r.text();
+ assert.ok(html.includes('Add raspi?') && html.includes('PCA9685 at 0x5f, ADS7830 at 0x48') && html.includes('imx219'));
+ assert.match(html, /<option value="adeept\.adr036" selected>[^<]*\(found on it\)<\/option>/);
+ assert.ok(html.includes('value="adeept.adr036.mecanum"'), 'the mecanum chassis looks the same on the bus: the owner picks it');
+ assert.ok(!html.includes('value="sim.rover"') && !html.includes('value="camera.onvif"'), 'only what a device can be');
+ assert.match(html, /name="live" value="on" checked/);
+ assert.ok(html.includes(`action="/link/${claim.id}/approve"`) && html.includes(`action="/link/${claim.id}/deny"`));
+ const wrong = await get('/link?code=ZZZZ-ZZZZ', alex);
+ assert.strictEqual(wrong.status, 404);
+ assert.ok((await wrong.text()).includes('No device is waiting with that code'));
+ assert.ok((await (await get('/link', alex)).text()).includes(`curl -fsSL ${t.config.installer.scriptUrl} | sh`));
+ });
+
+ await check('what a device reports is shown escaped and its hostname cut to a hostname', async () => {
+ const evil = (await start({ hostname: '<b>pi</b>', hardware: { found: ['<img src=x onerror=alert(1)>'], kit: 'local.rob_x' } })).json;
+ const html = await (await get(`/link?code=${evil.user_code}`, alex)).text();
+ assert.ok(html.includes('<img src=x onerror=alert(1)>') && !html.includes('<img src=x'));
+ assert.ok(html.includes('Add bpib?'), 'a hostname keeps only hostname characters');
+ assert.strictEqual((await t.db.maybe('SELECT suggested_profile FROM device_claims WHERE id = $1', [evil.id])).suggested_profile, null);
+ });
+
+ await check('approving: same-site only and a listed model; then the robot is the approver\'s', async () => {
+ const form = 'name=Garage+rover&profile_id=adeept.adr036&access_policy=private&live=on';
+ assert.strictEqual((await post(`/link/${claim.id}/approve`, alex, form, { Origin: 'https://evil.test' })).status, 403);
+ const bad = await post(`/link/${claim.id}/approve`, alex, form.replace('adeept.adr036', 'sim.rover'));
+ assert.strictEqual(bad.status, 422);
+ assert.ok((await bad.text()).includes('choose one of the models listed'));
+ const ok = await post(`/link/${claim.id}/approve`, alex, form);
+ assert.strictEqual(ok.status, 201);
+ const html = await ok.text();
+ const robot = (await t.domain.robots.list(alex.subject))[0];
+ robotId = robot.id;
+ assert.deepStrictEqual([robot.name, robot.profile_id, robot.access_policy], ['Garage rover', 'adeept.adr036', 'private']);
+ assert.ok(html.includes(`data-pair-robot="${robotId}"`) && html.includes('On your Live channel'));
+ assert.ok(html.includes(`href="https://openvibe.live/@alex?bind_robot=${robotId}"`), 'and the way to drive it from their chat');
+ assert.strictEqual((await post(`/link/${claim.id}/approve`, alex, form)).status, 404, 'a claim is approved once');
+ assert.strictEqual((await get(`/link?code=${claim.user_code}`, alex)).status, 404, 'and its code is spent');
+ });
+
+ await check('the device collects the pairing, pairs the usual way, and only then goes on Live', async () => {
+ const r = await poll(claim);
+ assert.strictEqual(r.json.state, 'approved');
+ assert.deepStrictEqual([r.json.robot_id, r.json.driver, r.json.profile_id, r.json.name], [robotId, 'adeept', 'adeept.adr036', 'Garage rover']);
+ assert.strictEqual(r.json.pairing.robot, robotId);
+ assert.match(r.json.pairing.code, /^[0-9A-Z]{4}-[0-9A-Z]{4}$/);
+ assert.strictEqual(r.json.panel_url, `${t.config.baseUrl}/panel/${robotId}`);
+ assert.strictEqual(r.json.live, true, 'the device can say where it shows');
+ assert.strictEqual((await poll(claim)).json.pairing.code, r.json.pairing.code, 'the device may ask again until it has paired');
+ const paired = await t.call('POST', '/api/v1/pair', { token: null, body: { robot: robotId, code: r.json.pairing.code, device_kind: 'onboard', name: 'raspi' } });
+ assert.strictEqual(paired.status, 201, paired.text);
+ assert.strictEqual(streamOf(robotId).mirror_to_live, true, 'the owner asked for its video on their channel');
+ const row = await t.db.maybe('SELECT state, pairing, live_applied_at FROM device_claims WHERE id = $1', [claim.id]);
+ assert.deepStrictEqual([row.state, row.pairing], ['paired', null], 'the pairing is gone once used');
+ assert.ok(row.live_applied_at);
+ assert.deepStrictEqual((await poll(claim)).json, { state: 'paired', robot_id: robotId });
+ });
+
+ await check('without the Live box the stream stays off Live', async () => {
+ const c = (await start({ hostname: 'shy', hardware: {} })).json;
+ assert.strictEqual((await post(`/link/${c.id}/approve`, alex, 'name=Shy&profile_id=relay.generic&access_policy=private')).status, 201);
+ const p = (await poll(c)).json;
+ assert.strictEqual(p.driver, 'none');
+ assert.strictEqual((await t.call('POST', '/api/v1/pair', { token: null, body: { robot: p.robot_id, code: p.pairing.code } })).status, 201);
+ assert.strictEqual(streamOf(p.robot_id).mirror_to_live, false);
+ });
+
+ await check('denying: the device is told no and nothing is made', async () => {
+ const c = (await start({ hostname: 'stranger' })).json;
+ assert.strictEqual((await post(`/link/${c.id}/deny`, bob, '', { Origin: 'https://evil.test' })).status, 403);
+ const r = await post(`/link/${c.id}/deny`, bob, '');
+ assert.strictEqual(r.status, 200);
+ assert.ok((await r.text()).includes('That device was told no'));
+ assert.deepStrictEqual((await poll(c)).json, { state: 'denied' });
+ assert.strictEqual((await post(`/link/${c.id}/approve`, bob, 'name=x&profile_id=adeept.adr036')).status, 404);
+ assert.strictEqual((await t.domain.robots.list(bob.subject)).length, 0);
+ });
+
+ await check('a code lasts 15 minutes', async () => {
+ const c = (await start({ hostname: 'late' })).json;
+ t.clock.offset += 16 * 60 * 1000;
+ try {
+ assert.strictEqual((await poll(c)).json.state, 'expired');
+ assert.strictEqual((await get(`/link?code=${c.user_code}`, alex)).status, 404);
+ } finally { t.clock.offset -= 16 * 60 * 1000; }
+ });
+
+ await check('starting a claim is counted by address', async () => {
+ let last;
+ for (let i = 0; i < 8 && (!last || last.status !== 429); i++) last = await start({ hostname: `flood${i}` });
+ assert.strictEqual(last.status, 429);
+ });
+
+ await t.close();
+ done();
+})();