openvibers/openvibe.bot Commits
Commissioned per-robot limits, up to a profile's hardware ceiling (#306)
Changes
@@@ -543,7 +543,9 @@ node token, and a service token is judged on `bot.job.dispatch` alone.| `GET /robots` | user (own robots), or `bot.robot.read` + `?owner=` | `{ robots[] }`; each carries `owner_subject`, `effective_limits` and `limits_source` (below) || `POST /robots` | the owner, or `bot.robot.manage` | `201 { robot, pairing{code,expires_at,installer} }`; with `BOT_PAIRING_AUTHORITY=network` the pairing is Network's and also carries `pairing_id`, and `503 bot.network_unavailable` if Network did not answer (no robot is created) || `GET /robots/:id` | a member, or `bot.robot.read` | `{ robot, role }`; `robot` carries `owner_subject`, `effective_limits` and `limits_source` (below) |--| `PATCH /robots/:id` | owner, or `bot.robot.manage` | `{ robot }`; new `limits` (including `allow`) re-send `config` to the connected device |++| `PATCH /robots/:id` | owner, or `bot.robot.manage` | `{ robot }`; new `limits` (including `allow`) re-send `config` to the connected device. A `max_speed`/`max_turn` above the profile default applies only once the robot is commissioned, and never above the profile's ceiling; only the owner's own person token may raise one (`403 bot.forbidden` for a service, even with `bot.robot.manage` acting for the owner) |++| `POST /robots/:id/commission` | **the owner, as a person**, body `{ confirm: true }` | `{ robot }` with `commissioned: { at, by }`; records who and when, one `robot.commission` audit row, re-sends `config`. `422 bot.confirm_required` without the confirm; `403 bot.forbidden` for an operator, an agent or any service token |++| `POST /robots/:id/uncommission` | **the owner, as a person** | `{ robot }` with `commissioned: null`: back to the profile's defaults at once, `config` re-sent, one `robot.uncommission` audit row || `DELETE /robots/:id` | owner, or `bot.robot.manage` | `204` || `POST /robots/:id/pairing-code` | owner, or `bot.robot.manage` | `201 { code, expires_at, installer }`; with `BOT_PAIRING_AUTHORITY=network` `201 { code, expires_at, pairing_id, installer }` minted by Network for the robot's owner, or `503 bot.network_unavailable` || `GET /robots/:id/operators` | member, or `bot.robot.read` | `{ operators[] }` |@@@ -599,6 +601,24 @@ the values the gate and the device really use:`effective_limits` is the same computation the command gate clamps with and the `config` frame sends, soit cannot disagree with what the robot does. `limits_source.robot` lists only the owner's overrides.++### Commissioning a robot for higher limits++++A profile's `limits` are every robot's defaults and stay the clamp until its owner commissions it. A profile++may also carry a hardware `ceiling` (`{ max_speed, max_turn }`; a profile without one has its `limits` as the++ceiling). Commissioning is a separate, owner-only action:++++```++POST /api/v1/robots/rob_…/commission++{ "confirm": true }++→ { "robot": { …, "commissioned": { "at": "2026-11-06T…", "by": "usr_…" } } }++```++++With it, `PATCH /robots/:id` may set `max_speed` and `max_turn` above the profile default and up to the++ceiling; without it they are clamped to the default exactly as before. `max_command_ms` is never raised (it++bounds how long a command may run). Uncommissioning returns to the defaults at once and the device is sent++the new config. The e-stop, roles, cooldowns and turn budget are unchanged, and operators and agents can++never commission or raise a limit: the route answers `403 bot.forbidden` for anyone but the owner's own++person token.---@@@ -610,6 +630,7 @@ reason, latency, time), kept 30 days. Bot's own halt at the end of a queue turn`operator_kind` `system`, no subject and `value` `{ reason: "turn_ended" | "turn_left" | "owner_takeover", turn_of }`; aqueue command refused because its turn ended while the gate decided is a `refused` row with reason `bot.turn_ended`. Changing a robot's streaming toggles writes one row per real change(`kind` `streaming.media` or `streaming.live`, `value` `{ on }`, role `owner`, result `ack`); an--idempotent repeat writes none. The outbox publishes `bot.robot.online`, `bot.robot.offline`,++idempotent repeat writes none. Commissioning writes `robot.commission` and uncommissioning++`robot.uncommission`, both as the owner. The outbox publishes `bot.robot.online`, `bot.robot.offline`,`bot.estop.set`, `bot.estop.cleared` and `bot.command.refused` — small payloads of ids, kinds andresults, never a credential, a pairing code, a publish key or a WHIP URL.nnew file mode 100644@@@ -0,0 +1,6 @@++-- phase: expand++-- Commissioned per-robot limits: the owner may raise max_speed/max_turn above the profile's default, up to++-- the profile's hardware ceiling, only after commissioning that robot. Without commissioning the profile++-- defaults still clamp every value; uncommissioning drops back to them at once.++ALTER TABLE robots ADD COLUMN commissioned_at timestamptz;++ALTER TABLE robots ADD COLUMN commissioned_by text; -- the owner's usr_ subject@@@ -17,6 +17,8 @@* | POST /robots | bot.robot.manage | the owner (new robot + pairing code)|* | GET /robots/:id | bot.robot.read | a member (owner/operator/viewer) |* | PATCH /robots/:id | bot.robot.manage | owner |++ * | POST /robots/:id/commission | bot.robot.manage | the owner, as a person, { confirm: true } |++ * | POST /robots/:id/uncommission | bot.robot.manage | the owner, as a person |* | DELETE /robots/:id | bot.robot.manage | owner |* | POST /robots/:id/pairing-code | bot.robot.manage | owner |* | GET|POST /robots/:id/operators | bot.robot.read/manage | owner (write) / member (read) |@@@ -275,10 +277,24 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm}));r.patch('/robots/:id', manage, wrap(async (req, res) => {await owner(req, req.params.id);-- const updated = await domain.robots.update(req.params.id, { name: req.body?.name, access_policy: req.body?.access_policy, limits: req.body?.limits });++ const updated = await domain.robots.update(req.params.id, { name: req.body?.name, access_policy: req.body?.access_policy, limits: req.body?.limits },++ { subject: me(req), kind: req.principal.kind });if (req.body?.limits !== undefined) await hub.refreshConfig(req.params.id); // the device enforces the new limits and allowlist at onceres.json({ robot: await presentRobot(updated) });}));++ // Commissioning (safety): the owner, as a person, may raise this robot's max_speed/max_turn above the++ // profile's defaults and up to its ceiling. The domain refuses a service even with bot.robot.manage, so++ // no agent or app can ever widen a robot's limits. Uncommissioning returns to the defaults at once.++ r.post('/robots/:id/commission', manage, wrap(async (req, res) => {++ const robot = await owner(req, req.params.id);++ const updated = await domain.robots.commission(robot.id, { subject: me(req), kind: req.principal.kind }, { confirm: req.body?.confirm });++ res.json({ robot: domain.present.robot(updated) });++ }));++ r.post('/robots/:id/uncommission', manage, wrap(async (req, res) => {++ const robot = await owner(req, req.params.id);++ const updated = await domain.robots.uncommission(robot.id, { subject: me(req), kind: req.principal.kind });++ res.json({ robot: domain.present.robot(updated) });++ }));r.delete('/robots/:id', manage, wrap(async (req, res) => {await owner(req, req.params.id);await domain.robots.remove(req.params.id);@@@ -84,6 +84,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =const presentRobot = (r) => (r ? {id: r.id, name: r.name, profile_id: r.profile_id, profile_version: r.profile_version,access_policy: r.access_policy, limits: json(r.limits, {}), agent_control: !!r.agent_control,++ commissioned: r.commissioned_at ? { at: iso(new Date(r.commissioned_at).getTime()), by: r.commissioned_by || null } : null,estop: { latched: !!r.estop_latched, by: r.estop_by || null, at: r.estop_at ? iso(new Date(r.estop_at).getTime()) : null },created_at: iso(new Date(r.created_at).getTime()), updated_at: iso(new Date(r.updated_at).getTime()),} : null);@@@ -185,17 +186,65 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =}return out;}-- async function updateRobot(id, { name, access_policy, limits }) {++ async function updateRobot(id, { name, access_policy, limits }, actor = null) {const sets = []; const args = [id];if (name !== undefined) { const n = text(name, 'name', 80); if (!n) fail(422, 'bot.invalid_input', 'name cannot be empty'); args.push(n); sets.push(`name = $${args.length}`); }if (access_policy !== undefined) { if (!['private', 'invite', 'queue'].includes(access_policy)) fail(422, 'bot.invalid_policy', 'bad access_policy'); args.push(access_policy); sets.push(`access_policy = $${args.length}`); }-- if (limits !== undefined) { args.push(JSON.stringify(cleanLimits(limits))); sets.push(`limits = $${args.length}::jsonb`); }++ if (limits !== undefined) {++ // Raising a drive limit above the profile default is the owner's own act, never an operator's, an++ // agent's or an app token's — commissioning is only the first half. Lower values stay settable.++ const robot = await getRobot(id);++ const row = robot ? await getProfile(db, robot.profile_id, robot.profile_version) : null;++ const pl = (row && row.profile && row.profile.limits) || {};++ const aboveDefault = (limits.max_speed != null && Number(limits.max_speed) > (pl.max_speed != null ? pl.max_speed : 1))++ || (limits.max_turn != null && Number(limits.max_turn) > (pl.max_turn != null ? pl.max_turn : 1));++ if (aboveDefault && (!actor || actor.kind !== 'user' || actor.subject !== robot.owner_subject)) {++ fail(403, 'bot.forbidden', 'only the owner may raise a robot\'s limits above the profile default');++ }++ args.push(JSON.stringify(cleanLimits(limits))); sets.push(`limits = $${args.length}::jsonb`);++ }if (!sets.length) return getRobot(id);args.push(iso(now()));sets.push(`updated_at = $${args.length}`);await db.query(`UPDATE robots SET ${sets.join(', ')} WHERE id = $1`, args);return getRobot(id);}++ /**++ * Commission a robot (safety-relevant): only its owner, as a person, and only with an explicit confirm.++ * It records who and when, and lets effectiveLimits accept max_speed/max_turn above the profile's++ * defaults and up to its ceiling. Operators, agents and services can never do this; the device gets the++ * new config at once. Idempotent: commissioning a commissioned robot writes nothing new.++ */++ async function commissionRobot(robotId, actor, { confirm } = {}) {++ const robot = await getRobot(robotId);++ if (!robot) fail(404, 'bot.robot_not_found', 'no such robot');++ if (!actor || actor.kind !== 'user' || actor.subject !== robot.owner_subject) {++ fail(403, 'bot.forbidden', 'only the owner may commission this robot');++ }++ if (confirm !== true) fail(422, 'bot.confirm_required', 'commissioning needs { confirm: true }');++ if (robot.commissioned_at) return robot;++ const at = iso(now());++ await db.query('UPDATE robots SET commissioned_at = $2, commissioned_by = $3, updated_at = $2 WHERE id = $1', [robotId, at, actor.subject]);++ await auditCommand({ robotId, subject: actor.subject, operatorKind: actor.kind, role: 'owner', kind: 'robot.commission', result: 'ack' });++ if (link && link.refreshConfig) await link.refreshConfig(robotId);++ if (link && link.broadcast) link.broadcast(robotId);++ return getRobot(robotId);++ }++ /** Drop a robot back to its profile's defaults at once (the owner only, as a person). */++ async function uncommissionRobot(robotId, actor) {++ const robot = await getRobot(robotId);++ if (!robot) fail(404, 'bot.robot_not_found', 'no such robot');++ if (!actor || actor.kind !== 'user' || actor.subject !== robot.owner_subject) {++ fail(403, 'bot.forbidden', 'only the owner may commission this robot');++ }++ if (!robot.commissioned_at) return robot;++ const at = iso(now());++ await db.query('UPDATE robots SET commissioned_at = NULL, commissioned_by = NULL, updated_at = $2 WHERE id = $1', [robotId, at]);++ await auditCommand({ robotId, subject: actor.subject, operatorKind: actor.kind, role: 'owner', kind: 'robot.uncommission', result: 'ack' });++ if (link && link.refreshConfig) await link.refreshConfig(robotId);++ if (link && link.broadcast) link.broadcast(robotId);++ return getRobot(robotId);++ }async function localProfile(robotId) {return db.maybe('SELECT source_profile_id, profile FROM robot_profiles WHERE robot_id = $1', [robotId]);}@@@ -1200,15 +1249,29 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =}// ── The control gate (ADR-043 decisions 5, 6, 8) ──────────────────────────────────────────────-- /** The owner's limits clamped by the profile's (a profile missing one falls back to Bot's defaults). */++ /**++ * The one place the effective limits are decided: the command gate clamps with it and the device's++ * `config` frame sends it, so they cannot disagree.++ *++ * The profile's `limits` are the defaults for everyone. Its `ceiling` (defaulting to the limits) is what++ * a *commissioned* robot's owner may raise max_speed/max_turn to; without commissioning the defaults++ * clamp exactly as they always did. max_command_ms is never raised: it bounds how long a command may run,++ * so the profile's value is its ceiling too.++ */const effectiveLimits = (robot, profile) => {const rl = json(robot.limits, {});const pl = (profile && profile.limits) || {};++ const pc = (profile && profile.ceiling) || {};const pSpeed = pl.max_speed != null ? pl.max_speed : 1;const pTurn = pl.max_turn != null ? pl.max_turn : 1;const pMs = pl.max_command_ms != null ? pl.max_command_ms : config.control.maxCommandMs;-- const maxSpeed = rl.max_speed != null ? Math.min(rl.max_speed, pSpeed) : pSpeed;-- const maxTurn = rl.max_turn != null ? Math.min(rl.max_turn, pTurn) : pTurn;++ const cSpeed = pc.max_speed != null ? Math.max(pc.max_speed, pSpeed) : pSpeed;++ const cTurn = pc.max_turn != null ? Math.max(pc.max_turn, pTurn) : pTurn;++ const commissioned = !!robot.commissioned_at;++ const speedCap = commissioned ? cSpeed : pSpeed;++ const turnCap = commissioned ? cTurn : pTurn;++ const maxSpeed = rl.max_speed != null ? Math.min(rl.max_speed, speedCap) : pSpeed;++ const maxTurn = rl.max_turn != null ? Math.min(rl.max_turn, turnCap) : pTurn;const maxCommandMs = rl.max_command_ms != null ? Math.min(rl.max_command_ms, pMs) : pMs;const cooldownMs = rl.cooldown_ms != null ? rl.cooldown_ms : config.control.cooldownMs;return { maxSpeed, maxTurn, maxCommandMs, cooldownMs };@@@ -1423,7 +1486,8 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =return {db, now, config, log, outbox, link,present: { robot: presentRobot, device: presentDevice, video: presentVideo },-- robots: { create: createRobot, list: listRobots, get: getRobot, update: updateRobot, setEmbedPublic, setListed, listListed, setAgentControl, remove: removeRobot,++ robots: { create: createRobot, list: listRobots, get: getRobot, update: updateRobot, commission: commissionRobot, uncommission: uncommissionRobot,++ 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, onPaired },@@@ -16,7 +16,8 @@* commands: { drive: { axes: { throttle: [-1, 1], … } }, … }, // what the device accepts (COMMAND_SCHEMAS)* widgets: [{ type: "drive", capability: …, label: …, command: { kind: "drive" } }], // types in WIDGETS* camera: { transport: "whip|onvif|rtsp", resolution },-- * limits: { max_speed, max_turn, max_command_ms, heartbeat_ms } }++ * limits: { max_speed, max_turn, max_command_ms, heartbeat_ms },++ * ceiling: { max_speed, max_turn } } // optional: what a commissioned owner may raise to** `commands` is the contract with the device's plugin: the gate allows only the kinds declared there* (plus `halt`, which every profile takes) and builds every value from it, so a panel control can only@@@ -203,12 +204,24 @@ function validateProfile(p) {};if (!(limits.max_speed > 0) || !(limits.max_turn > 0)) bad(`${p.id}: max_speed and max_turn must be positive`);if (!(limits.max_command_ms > 0) || !(limits.heartbeat_ms > 0)) bad(`${p.id}: max_command_ms and heartbeat_ms must be positive`);-- const contract = validate('bot.robot-profile@1', p);++ // A hardware ceiling is what a commissioned owner may raise the two drive limits to. It defaults to the++ // limits themselves, so a profile without one keeps today's behaviour exactly. The pinned contract does++ // not know the field yet, so the contract check runs on the profile without it.++ const c = p.ceiling && typeof p.ceiling === 'object' ? p.ceiling : {};++ const ceiling = {++ max_speed: number(c.max_speed) != null ? number(c.max_speed) : limits.max_speed,++ max_turn: number(c.max_turn) != null ? number(c.max_turn) : limits.max_turn,++ };++ if (!(ceiling.max_speed > 0) || !(ceiling.max_turn > 0)) bad(`${p.id}: ceiling.max_speed and ceiling.max_turn must be positive`);++ if (ceiling.max_speed < limits.max_speed) bad(`${p.id}: ceiling.max_speed is below limits.max_speed`);++ if (ceiling.max_turn < limits.max_turn) bad(`${p.id}: ceiling.max_turn is below limits.max_turn`);++ const { ceiling: _ceiling, ...contracted } = p;++ const contract = validate('bot.robot-profile@1', contracted);if (!contract.valid) bad(`bot.robot-profile@1: ${JSON.stringify(contract.errors)}`);return {id: p.id, version: p.version, name: p.name.trim(), vendor: p.vendor || null, kind: p.kind || null,description: p.description || null, capabilities: [...p.capabilities], variants: p.variants || null,-- mapping: p.mapping, commands, widgets, camera: p.camera || null, limits,++ mapping: p.mapping, commands, widgets, camera: p.camera || null, limits, ceiling,};}@@@ -220,7 +233,8 @@ function validateProfile(p) {*/function checkProfile(value) {if (!isObject(value)) return { valid: false, problems: ['a profile must be a JSON object'] };-- const contract = validate('bot.robot-profile@1', value);++ const { ceiling: _ceiling, ...contracted } = value;++ const contract = validate('bot.robot-profile@1', contracted);if (!contract.valid) {return { valid: false, problems: (contract.errors || []).map((e) => {const at = String(e.path || '').replace(/^\//, '').replace(/\//g, '.');@@@ -17,5 +17,6 @@{ "type": "battery", "capability": "battery", "label": "Battery", "order": 3 },{ "type": "latency", "label": "Latency", "order": 4 }],-- "limits": { "max_speed": 0.5, "max_turn": 0.6, "max_command_ms": 300, "heartbeat_ms": 1000 }++ "limits": { "max_speed": 0.5, "max_turn": 0.6, "max_command_ms": 300, "heartbeat_ms": 1000 },++ "ceiling": { "max_speed": 1, "max_turn": 1 }}@@@ -266,6 +266,7 @@ function renderProfilesPage({ config, source = '', result = null } = {}) {<li><code>widgets</code> — the panel, in order. Each is <code>{ type, capability?, label?, command? }</code>; types are <code>drive</code>, <code>pan-tilt</code>, <code>servo</code>, <code>lights</code>, <code>horn</code>, <code>speaker</code>, <code>display</code>, <code>telemetry</code>, <code>battery</code>, <code>latency</code>, <code>ptz</code>, <code>camera</code>, <code>head</code>, <code>lift</code>, <code>buttons</code>, <code>video_click</code>. A widget that sends commands names them in <code>command</code>, checked against <code>commands</code> at load.</li><li><code>camera</code> — optional: <code>{ transport: "whip"|"onvif"|"rtsp", resolution }</code>.</li><li><code>limits</code> — optional, with defaults: <code>max_speed</code> and <code>max_turn</code> (1), <code>max_command_ms</code> (300), <code>heartbeat_ms</code> (1000). Positive; the stricter of these and the server's own limits applies.</li>++<li><code>ceiling</code> — optional: <code>{ max_speed, max_turn }</code>, what a robot's owner may raise those two to after commissioning it. It defaults to <code>limits</code>, so a profile without one never goes above them.</li></ul><p class="muted">The schema is <code>bot.robot-profile@1</code>; capability and widget names are open strings checked against the registries in <code>server/profiles/index.js</code>, so a new one is one line there, never a schema change.</p></section>@@@ -302,6 +303,13 @@ app.post('/robot-events', express.text({ type: 'application/json' }), (req, res)const ACCOUNT_CALL = `curl https://openvibe.bot/api/v1/me \\-H "Authorization: Bearer $OPENVIBE_TOKEN"# → {"subject": "usr_…", "username": "alex", "display_name": "Alex"}`;++const COMMISSION_CALL = `curl -X POST https://openvibe.bot/api/v1/robots/rob_…/commission \\++ -H "Authorization: Bearer $OPENVIBE_TOKEN" -H "Content-Type: application/json" \\++ -d '{"confirm": true}'++# → {"robot": {"id": "rob_…", "commissioned": {"at": "…", "by": "usr_…"}, …}}++# Undo it, back to the profile's defaults at once:++curl -X POST https://openvibe.bot/api/v1/robots/rob_…/uncommission \\++ -H "Authorization: Bearer $OPENVIBE_TOKEN"`;const STEPS_CALL = `curl -X POST https://openvibe.bot/api/v1/robots/rob_…/steps \\-H "Authorization: Bearer $OPENVIBE_TOKEN" -H "Content-Type: application/json" \\@@@ -350,6 +358,10 @@ ${code(STEPS_CALL)}<p>Every person is a <code>usr_…</code> subject, and it is what an owner names when they add an operator or hand a robot to a service. Your own token answers it:</p>${code(ACCOUNT_CALL)}<p>A robot's answer carries its owner and the limits it really enforces, so an integrator never has to read <code>limits: {}</code> as "no limits": <code>owner_subject</code>, <code>effective_limits</code> (<code>max_speed</code>, <code>max_turn</code>, <code>max_command_ms</code>, <code>heartbeat_ms</code>) and <code>limits_source</code> (<code>profile</code> and <code>robot</code>). The same ID is on <a href="https://openvibe.network/my">openvibe.network/my</a>, and Network answers it at <code>GET https://openvibe.network/api/auth/me</code> as <code>user.subject_id</code>.</p></section>++<section class="card" aria-labelledby="commission-h"><h2 id="commission-h">Higher limits, commissioned</h2>++<p>A profile's <code>limits</code> are every robot's defaults and clamp its commands. If the hardware can do more, the profile names a <code>ceiling</code>, and the robot's owner may — only after commissioning that robot — set its own <code>max_speed</code> and <code>max_turn</code> above the default and up to that ceiling. Without commissioning they are clamped to the default exactly as before, and <code>max_command_ms</code> is never raised. Commissioning records who and when in the audit; uncommissioning returns the robot to the defaults at once. Operators, apps and agents can never commission a robot or raise a limit.</p>++${code(COMMISSION_CALL)}++<p class="muted">On the panel it is under <b>Commissioned limits</b>. The same action is <code>POST /api/v1/robots/:id/commission</code> (body <code>{ "confirm": true }</code>) and <code>POST /api/v1/robots/:id/uncommission</code>.</p></section>`;return docsPage({ config, path: '/docs/automation', title: 'Routines, webhooks and your agent', description: 'Make a robot run steps on a schedule, send its events to your server signed, and let your OpenVibe.Actor agent drive it, all through the same gate.', content });}@@@ -511,6 +511,39 @@ ${row('Longest command', 'max_command_ms', `${effective.maxCommandMs} ms`)}</section>`;}++++/**++ * Commissioning (owner only): raise this robot's max_speed/max_turn above the profile's defaults, up to its++ * ceiling, and drop back to the defaults at once. One explicit form each way; nothing here applies to an++ * operator or an agent.++ */++function commissionForm(robot, profile) {++ const limits = profile.limits || {};++ const ceiling = profile.ceiling || limits;++ const at = robot.commissioned || null;++ const own = robot.limits || {};++ const allow = `up to ${esc(ceiling.max_speed)} speed and ${esc(ceiling.max_turn)} turn`;++ const when = (iso) => { const d = new Date(iso); return isNaN(d) ? esc(iso) : esc(d.toISOString().slice(0, 16).replace('T', ' ')) + ' UTC'; };++ if (at) {++ // Commissioned: the owner sets this robot's own drive limits here (or with PATCH /api/v1/robots/:id).++ const field = (name, label, value, cap) => `<label class="setting-field"><span>${label}</span><input type="number" name="${name}" min="0.05" max="${esc(cap)}" step="0.05" value="${esc(value)}" required inputmode="decimal"><small>${esc(limits[name])} by default, at most ${esc(cap)}</small></label>`;++ return `<section class="setting-card" id="commission" aria-labelledby="commission-h"><h2 id="commission-h">Commissioned limits</h2>++<p class="setting-ok" role="status">Commissioned ${when(at.at)}. This robot may use your own speed and turn ${allow}.</p>++<form method="post" action="/robots/${esc(robot.id)}/limits" class="setting-form">++${field('max_speed', 'Top speed', own.max_speed != null ? own.max_speed : limits.max_speed, ceiling.max_speed)}++${field('max_turn', 'Top turn', own.max_turn != null ? own.max_turn : limits.max_turn, ceiling.max_turn)}++<button type="submit" class="primary">Save the limits</button></form>++<p class="setting-note">The robot gets them at once. The e-stop, cooldowns and command length (${esc(limits.max_command_ms)} ms) stay as they are.</p>++<form method="post" action="/robots/${esc(robot.id)}/uncommission"><button type="submit" class="quiet">Return to the profile defaults (${esc(limits.max_speed)} speed, ${esc(limits.max_turn)} turn)</button></form></section>`;++ }++ // Not commissioned: one deliberate act, with the owner saying what they checked.++ return `<section class="setting-card" id="commission" aria-labelledby="commission-h"><h2 id="commission-h">Commissioned limits</h2>++<p class="setting-note">The profile's speed (${esc(limits.max_speed)}) and turn (${esc(limits.max_turn)}) limits clamp every command. Commissioning this robot lets you set its own higher values ${allow}. It records that you did it and when, and it is yours alone: operators and your agent can never do it.</p>++<form method="post" action="/robots/${esc(robot.id)}/commission" class="setting-form">++<label class="setting-check"><input type="checkbox" name="confirm" value="on" required> I have checked this robot can move faster safely: there is clear space around it, and I can reach its e-stop.</label>++<button type="submit" class="primary">Commission this robot</button></form></section>`;++}++function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs = 150, mode = 'page', signedIn = true, streaming = null, people = null, signaling_url = null, profileForm = {}, tryIt = false, automation = null, parentOrigins = [], liveUrl = null, limitsInfo = null }) {const embed = mode === 'embed';const latched = !!(robot.estop && robot.estop.latched);@@@ -538,7 +571,7 @@ ${embed && role === 'watcher' && !signedIn ? `<p class="embed-sign-in" data-driv<div class="widgets">${widgets.join('\n')}</div>--${tryIt ? tryOutro(profile) : ''}${role === 'owner' && !embed && !tryIt ? `<div class="owner-settings">${limitsCard(robot, limitsInfo, profile)}${onStreamCard(robot, { liveUrl })}${localProfileForm(robot, profile, profileForm)}${automation ? routinesCard(robot, profile, automation) + webhooksCard(robot, automation) : ''}${agentForm(robot)}${listingForm(robot)}${embedForm(robot)}${streamingForm(robot, streaming)}${people ? peopleCard(robot, people) : ''}</div>` : ''}++${tryIt ? tryOutro(profile) : ''}${role === 'owner' && !embed && !tryIt ? `<div class="owner-settings">${limitsCard(robot, limitsInfo, profile)}${onStreamCard(robot, { liveUrl })}${localProfileForm(robot, profile, profileForm)}${commissionForm(robot, profile)}${automation ? routinesCard(robot, profile, automation) + webhooksCard(robot, automation) : ''}${agentForm(robot)}${listingForm(robot)}${embedForm(robot)}${streamingForm(robot, streaming)}${people ? peopleCard(robot, people) : ''}</div>` : ''}</main>`;return page(tryIt ? 'Try the panel with a simulated rover' : robot.name, body, {scripts: panelScripts(profile, tryIt), bodyClass: embed ? 'embed-page' : 'panel-page',@@@ -17,6 +17,8 @@* POST /robots/:id/embed the owner only: allow (embed_public=on) or stop (off) anonymous read-only embedding → 303 to its panel* POST /robots/:id/on-stream the owner only: show the panel where it is embedded, then 303 to their Live channel's bind prompt* POST /robots/:id/access the owner only: who may drive (private | invite | queue) → 303 to its panel++ * POST /robots/:id/commission, /uncommission the owner only: per-robot limits above the profile default → 303++ * POST /robots/:id/limits the owner only, commissioned: this robot's top speed and turn → 303* POST /robots/:id/operators the owner only: add a person by @username with a role → 303 to its panel* POST /robots/:id/operators/:subject/remove the owner only: remove a person (never the owner row) → 303* POST /robots/:id/queue/leave any signed-in person: leave the robot's turn queue → 303@@@ -113,7 +115,7 @@ const LLMS_PAGES = [{path: '/docs/automation', title: 'Routines, webhooks and your agent',note: 'Steps on a schedule, signed webhooks for a robot\'s events, and the switch that lets your OpenVibe.Actor agent drive.',-- text: 'How a robot on OpenVibe.Bot moves without anyone at the controls, through the same gate as a person (limits, cooldowns, e-stop, audit). GET /api/v1/me answers a person\'s account ID (subject, username, display_name); a robot\'s answer carries owner_subject, effective_limits (max_speed, max_turn, max_command_ms, heartbeat_ms) and limits_source. Steps are plain lines checked against the robot\'s profile: forward, back, left and right for seconds at a speed, stop, wait, say, face, show, an actuator or camera axis with a value and a hold time, and press for a button; held steps are re-sent inside the deadman window and a refused step stops the rest with its reason. Routines run steps as the owner on demand, every N minutes (at least 5), daily at a UTC time on chosen weekdays, or each time the robot comes online; one runs at a time; 60 steps, 60 seconds a step, 10 minutes in all. Webhooks send a robot\'s bot.robot.online, bot.robot.offline, bot.estop.set, bot.estop.cleared and bot.command.refused events to up to five https addresses, each with its own whsec_ secret shown once, signed like OpenVibe.Events deliveries (X-OpenVibe-Timestamp, X-OpenVibe-Signature-V2, checked with verifyDeliveryV2 from openvibe-sdk/events), to public addresses only, retried after 30 seconds, 2, 10, 30 and 120 minutes. The Your agent switch, off until the owner turns it on, lets the owner\'s OpenVibe.Actor agent see their robots and run steps on that one as the owner, never for anyone else; a steps call on Bot\'s API runs up to 20 steps and 30 seconds and answers done, or stopped with the reason.',++ text: 'How a robot on OpenVibe.Bot moves without anyone at the controls, through the same gate as a person (limits, cooldowns, e-stop, audit). GET /api/v1/me answers a person\'s account ID (subject, username, display_name); a robot\'s answer carries owner_subject, effective_limits (max_speed, max_turn, max_command_ms, heartbeat_ms) and limits_source. POST /api/v1/robots/:id/commission lets its owner raise max_speed and max_turn above the profile default, up to the profile ceiling, and POST /robots/:id/uncommission returns to the defaults; only the owner as a person may do either. Steps are plain lines checked against the robot\'s profile: forward, back, left and right for seconds at a speed, stop, wait, say, face, show, an actuator or camera axis with a value and a hold time, and press for a button; held steps are re-sent inside the deadman window and a refused step stops the rest with its reason. Routines run steps as the owner on demand, every N minutes (at least 5), daily at a UTC time on chosen weekdays, or each time the robot comes online; one runs at a time; 60 steps, 60 seconds a step, 10 minutes in all. Webhooks send a robot\'s bot.robot.online, bot.robot.offline, bot.estop.set, bot.estop.cleared and bot.command.refused events to up to five https addresses, each with its own whsec_ secret shown once, signed like OpenVibe.Events deliveries (X-OpenVibe-Timestamp, X-OpenVibe-Signature-V2, checked with verifyDeliveryV2 from openvibe-sdk/events), to public addresses only, retried after 30 seconds, 2, 10, 30 and 120 minutes. The Your agent switch, off until the owner turns it on, lets the owner\'s OpenVibe.Actor agent see their robots and run steps on that one as the owner, never for anyone else; a steps call on Bot\'s API runs up to 20 steps and 30 seconds and answers done, or stopped with the reason.',},{path: '/docs/computers', title: 'Your computer for your agents',@@@ -989,6 +991,48 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limires.redirect(303, `/panel/${robot.id}#on-stream`);}));++ // Commissioning, on the owner's page: one form each way, and the same domain action /api/v1 calls. Only the++ // owner as a person may do it; the device's config is re-sent by the domain at once.++ r.post('/robots/:id/commission', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {++ const me = requireUser(req);++ if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');++ const robot = await domain.robots.get(req.params.id);++ if (!robot) throw new BotError(404, 'bot.robot_not_found', 'no such robot');++ if (robot.owner_subject !== me.subject) throw new BotError(403, 'bot.forbidden', 'only the owner may commission this robot');++ // The owner ticks what they checked; a form without it commissions nothing.++ if (!req.body || req.body.confirm !== 'on') throw new BotError(422, 'bot.confirm_required', 'tick the box to say the robot can move faster safely');++ await domain.robots.commission(robot.id, { subject: me.subject, kind: 'user' }, { confirm: true });++ if (hub) hub.broadcast(robot.id);++ return res.redirect(303, `/panel/${robot.id}#commission`);++ }));++ // A commissioned robot's own drive limits, from the owner's page: the same domain update /api/v1 PATCH makes++ // (the raise guard and the ceiling clamp are there), then the device gets the new config.++ r.post('/robots/:id/limits', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {++ const me = requireUser(req);++ if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');++ const robot = await domain.robots.get(req.params.id);++ if (!robot) throw new BotError(404, 'bot.robot_not_found', 'no such robot');++ if (robot.owner_subject !== me.subject) throw new BotError(403, 'bot.forbidden', 'only the owner may set this robot\'s limits');++ const num = (v) => { const n = Number(v); return Number.isFinite(n) && n > 0 && n <= 1 ? n : null; };++ const speed = num(req.body && req.body.max_speed), turn = num(req.body && req.body.max_turn);++ if (speed == null || turn == null) throw new BotError(422, 'bot.invalid_input', 'speed and turn are numbers above 0, at most 1');++ const stored = typeof robot.limits === 'string' ? JSON.parse(robot.limits || '{}') : (robot.limits || {});++ const limits = { ...stored, max_speed: speed, max_turn: turn };++ await domain.robots.update(robot.id, { limits }, { subject: me.subject, kind: 'user' });++ if (hub) { await hub.refreshConfig(robot.id); hub.broadcast(robot.id); }++ return res.redirect(303, `/panel/${robot.id}#commission`);++ }));++ r.post('/robots/:id/uncommission', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {++ const me = requireUser(req);++ if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');++ const robot = await domain.robots.get(req.params.id);++ if (!robot) throw new BotError(404, 'bot.robot_not_found', 'no such robot');++ if (robot.owner_subject !== me.subject) throw new BotError(403, 'bot.forbidden', 'only the owner may commission this robot');++ await domain.robots.uncommission(robot.id, { subject: me.subject, kind: 'user' });++ if (hub) hub.broadcast(robot.id);++ return res.redirect(303, `/panel/${robot.id}#commission`);++ }));++r.post('/robots/:id/embed', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {const me = requireUser(req);if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');nnew file mode 100644@@@ -0,0 +1,145 @@++'use strict';++// Commissioned per-robot limits (safety): the owner, as a person, may raise max_speed/max_turn above the++// profile's defaults and up to its ceiling only after commissioning; everyone else never can; the device++// receives the same effective values the gate uses.++const assert = require('assert');++const { boot, check, done } = require('./helpers/app');++const { loadProfiles } = require('../server/profiles');++++(async () => {++ const t = await boot();++ const alex = t.network.newUser('alex');++ const bob = t.network.newUser('bob');++ const profiles = loadProfiles();++ const eff = async (robotId) => {++ const row = await t.domain.robots.get(robotId);++ return t.domain.control.effectiveLimits(row, profiles.get(row.profile_id));++ };++ const patch = (robotId, limits, user = alex) => t.call('PATCH', `/api/v1/robots/${robotId}`, { user, body: { limits } });++ const commission = (robotId, body = { confirm: true }, user = alex) => t.call('POST', `/api/v1/robots/${robotId}/commission`, { user, body });++ const uncommission = (robotId, user = alex) => t.call('POST', `/api/v1/robots/${robotId}/uncommission`, { user, body: {} });++++ await check('LOOI carries a ceiling above its defaults; a profile without one keeps its limits as the ceiling', () => {++ assert.deepStrictEqual(profiles.get('looi').ceiling, { max_speed: 1, max_turn: 1 });++ const cozmo = profiles.get('cozmo');++ assert.deepStrictEqual(cozmo.ceiling, { max_speed: cozmo.limits.max_speed, max_turn: cozmo.limits.max_turn }, 'no ceiling: the limits clamp');++ assert.deepStrictEqual(profiles.get('sim.rover').ceiling, { max_speed: profiles.get('sim.rover').limits.max_speed, max_turn: profiles.get('sim.rover').limits.max_turn });++ });++++ await check('without commissioning, values above the profile default clamp to the default exactly as today', async () => {++ const { robot } = await t.robot(alex, { profile_id: 'looi', limits: { max_speed: 0.9, max_turn: 0.9 } });++ assert.deepStrictEqual(await eff(robot.id), { maxSpeed: 0.5, maxTurn: 0.6, maxCommandMs: 300, cooldownMs: 0 });++ });++++ await check('commissioning needs the owner, a person and an explicit confirm', async () => {++ const { robot } = await t.robot(alex, { profile_id: 'looi' });++ const noConfirm = await commission(robot.id, {});++ assert.strictEqual(noConfirm.status, 422);++ assert.strictEqual(noConfirm.json.code, 'bot.confirm_required');++ await t.call('POST', `/api/v1/robots/${robot.id}/operators`, { user: alex, body: { subject: bob.subject, role: 'operator' } });++ const byOperator = await commission(robot.id, { confirm: true }, bob);++ assert.strictEqual(byOperator.status, 403, byOperator.text);++ // Even a service acting for the owner (X-OV-Subject) may not: only the owner's own person token.++ const byService = await t.call('POST', `/api/v1/robots/${robot.id}/commission`, {++ cap: ['bot.robot.manage'], headers: { 'x-ov-subject': alex.subject }, body: { confirm: true },++ });++ assert.strictEqual(byService.status, 403, 'an app token with manage still never commissions');++ assert.strictEqual((await t.domain.robots.get(robot.id)).commissioned_at, null);++ });++++ await check('an app token may lower a limit but never raise one, even on a commissioned robot', async () => {++ const { robot } = await t.robot(alex, { profile_id: 'looi' });++ await commission(robot.id);++ const asOwner = { cap: ['bot.robot.manage'], headers: { 'x-ov-subject': alex.subject } };++ const raise = await t.call('PATCH', `/api/v1/robots/${robot.id}`, { ...asOwner, body: { limits: { max_speed: 0.9 } } });++ assert.strictEqual(raise.status, 403, raise.text);++ assert.strictEqual((await eff(robot.id)).maxSpeed, 0.5);++ const lower = await t.call('PATCH', `/api/v1/robots/${robot.id}`, { ...asOwner, body: { limits: { max_speed: 0.3 } } });++ assert.strictEqual(lower.status, 200, lower.text);++ assert.strictEqual((await eff(robot.id)).maxSpeed, 0.3);++ });++++ await check('the owner commissions: who and when are recorded, and higher values apply up to the ceiling', async () => {++ const { robot } = await t.robot(alex, { profile_id: 'looi' });++ const r = await commission(robot.id);++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.json.robot.commissioned.by, alex.subject);++ assert.ok(r.json.robot.commissioned.at);++ assert.strictEqual((await patch(robot.id, { max_speed: 0.8, max_turn: 0.9 })).status, 200);++ assert.deepStrictEqual(await eff(robot.id), { maxSpeed: 0.8, maxTurn: 0.9, maxCommandMs: 300, cooldownMs: 0 });++ const audit = await t.domain.audit.list(robot.id, { limit: 20 });++ const row = audit.find((a) => a.kind === 'robot.commission');++ assert.ok(row && row.operator_subject === alex.subject && row.result === 'ack');++ });++++ await check('never above the ceiling', async () => {++ const { robot } = await t.robot(alex, { profile_id: 'looi' });++ await commission(robot.id);++ await patch(robot.id, { max_speed: 5, max_turn: 5 });++ assert.deepStrictEqual(await eff(robot.id), { maxSpeed: 1, maxTurn: 1, maxCommandMs: 300, cooldownMs: 0 });++ });++++ await check('max_command_ms is never raised, even when commissioned', async () => {++ const { robot } = await t.robot(alex, { profile_id: 'looi' });++ await commission(robot.id);++ await patch(robot.id, { max_command_ms: 5000 });++ assert.deepStrictEqual(await eff(robot.id), { maxSpeed: 0.5, maxTurn: 0.6, maxCommandMs: 300, cooldownMs: 0 });++ });++++ await check('uncommissioning drops back to the defaults at once, and the audit says so', async () => {++ const { robot } = await t.robot(alex, { profile_id: 'looi' });++ await commission(robot.id);++ await patch(robot.id, { max_speed: 0.9, max_turn: 0.9 });++ assert.strictEqual((await eff(robot.id)).maxSpeed, 0.9);++ const r = await uncommission(robot.id);++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.json.robot.commissioned, null);++ assert.deepStrictEqual(await eff(robot.id), { maxSpeed: 0.5, maxTurn: 0.6, maxCommandMs: 300, cooldownMs: 0 });++ const audit = await t.domain.audit.list(robot.id, { limit: 20 });++ assert.ok(audit.some((a) => a.kind === 'robot.uncommission' && a.result === 'ack'));++ });++++ await check('the device receives the effective values: defaults, higher after commissioning, defaults again', async () => {++ const { robot, pairing } = await t.robot(alex, { profile_id: 'looi', limits: { max_speed: 0.9 } });++ const p = await t.call('POST', '/api/v1/pair', { token: null, body: { robot: robot.id, code: pairing.code } });++ const dev = await t.ws('/device', { headers: { Authorization: `Bearer ${p.json.credential}` } });++ await dev.waitFor((m) => m.type === 'hello');++ const first = await dev.waitFor((m) => m.type === 'config');++ assert.deepStrictEqual(first.limits, { max_speed: 0.5, max_turn: 0.6, max_command_ms: 300, heartbeat_ms: 1000 }, 'clamped before commissioning');++ await commission(robot.id);++ await dev.waitFor((m) => m.type === 'config' && m.seq > first.seq);++ const high = await patch(robot.id, { max_speed: 0.9, max_turn: 0.9 });++ assert.strictEqual(high.status, 200);++ const second = await dev.waitFor((m) => m.type === 'config' && m.seq > first.seq && m.limits.max_turn === 0.9);++ assert.deepStrictEqual(second.limits, { max_speed: 0.9, max_turn: 0.9, max_command_ms: 300, heartbeat_ms: 1000 });++ await uncommission(robot.id);++ const third = await dev.waitFor((m) => m.type === 'config' && m.seq > second.seq && m.limits.max_speed === 0.5);++ assert.deepStrictEqual(third.limits, { max_speed: 0.5, max_turn: 0.6, max_command_ms: 300, heartbeat_ms: 1000 }, 'at once, on the device');++ dev.close();++ });++++ await check('the owner\'s page: commissioning needs the tick; the limits form saves within the ceiling; others are refused', async () => {++ const cookie = (user) => ({ Cookie: `ov_token=${t.network.signUser(user)}` });++ const post = (p, user, form) => fetch(t.base + p, { method: 'POST', redirect: 'manual', body: new URLSearchParams(form).toString(),++ headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...(user ? cookie(user) : {}) } });++ const { robot } = await t.robot(alex, { profile_id: 'looi' });++ const page = await (await fetch(`${t.base}/panel/${robot.id}`, { headers: cookie(alex) })).text();++ assert.match(page, /<input type="checkbox" name="confirm" value="on" required>/, 'the form asks the owner to say what they checked');++ assert.strictEqual((await post(`/robots/${robot.id}/commission`, alex, {})).status, 422, 'no tick, no commissioning');++ assert.strictEqual((await t.domain.robots.get(robot.id)).commissioned_at, null);++ assert.strictEqual((await post(`/robots/${robot.id}/commission`, bob, { confirm: 'on' })).status, 403, 'not the owner');++ assert.strictEqual((await post(`/robots/${robot.id}/commission`, alex, { confirm: 'on' })).status, 303);++ assert.ok((await t.domain.robots.get(robot.id)).commissioned_at, 'the tick and the owner commission it');++ assert.match(await (await fetch(`${t.base}/panel/${robot.id}`, { headers: cookie(alex) })).text(), /name="max_speed" min="0.05" max="1"/, 'then the page sets its own limits, up to the ceiling');++ assert.strictEqual((await post(`/robots/${robot.id}/limits`, bob, { max_speed: '1', max_turn: '1' })).status, 403, 'others cannot set them');++ assert.strictEqual((await post(`/robots/${robot.id}/limits`, alex, { max_speed: '2', max_turn: '1' })).status, 422, 'above 1 is refused');++ assert.strictEqual((await post(`/robots/${robot.id}/limits`, alex, { max_speed: '0.9', max_turn: '0.8' })).status, 303);++ assert.deepStrictEqual(await eff(robot.id), { maxSpeed: 0.9, maxTurn: 0.8, maxCommandMs: 300, cooldownMs: (await eff(robot.id)).cooldownMs }, 'saved and in force');++ assert.strictEqual((await post(`/robots/${robot.id}/uncommission`, alex, {})).status, 303);++ const back = await eff(robot.id);++ assert.deepStrictEqual([back.maxSpeed, back.maxTurn], [0.5, 0.6], 'uncommissioned: the defaults at once, whatever is stored');++ });++++ await t.close();++ done();++})();@@@ -1,8 +1,8 @@'use strict';// /llms.txt and /llms-full.txt (llmstxt.org): the plain-text map of Bot's public pages, built with// openvibe-shared/seo. The same pages as the sitemap minus /install (a 302 to the installer script, not a--// page); no signed-in or per-robot page, and nothing under the API or /internal — except the two routes a--// page names on purpose so an agent can find an account id and a robot's real limits (GET /me, /robots).++// page); no signed-in or per-robot page, and nothing under the API or /internal — except the routes a page++// names on purpose so an agent finds an account id, a robot's real limits and commissioning (GET /me, /robots).const assert = require('assert');const { boot, check, done } = require('./helpers/app');
@@ -543,7 +543,9 @@ node token, and a service token is judged on `bot.job.dispatch` alone.
@@ -543,7 +543,9 @@ node token, and a service token is judged on `bot.job.dispatch` alone.
| `GET /robots` | user (own robots), or `bot.robot.read` + `?owner=` | `{ robots[] }`; each carries `owner_subject`, `effective_limits` and `limits_source` (below) |
| `GET /robots` | user (own robots), or `bot.robot.read` + `?owner=` | `{ robots[] }`; each carries `owner_subject`, `effective_limits` and `limits_source` (below) |
| `POST /robots` | the owner, or `bot.robot.manage` | `201 { robot, pairing{code,expires_at,installer} }`; with `BOT_PAIRING_AUTHORITY=network` the pairing is Network's and also carries `pairing_id`, and `503 bot.network_unavailable` if Network did not answer (no robot is created) |
| `POST /robots` | the owner, or `bot.robot.manage` | `201 { robot, pairing{code,expires_at,installer} }`; with `BOT_PAIRING_AUTHORITY=network` the pairing is Network's and also carries `pairing_id`, and `503 bot.network_unavailable` if Network did not answer (no robot is created) |
| `GET /robots/:id` | a member, or `bot.robot.read` | `{ robot, role }`; `robot` carries `owner_subject`, `effective_limits` and `limits_source` (below) |
| `GET /robots/:id` | a member, or `bot.robot.read` | `{ robot, role }`; `robot` carries `owner_subject`, `effective_limits` and `limits_source` (below) |
-| `PATCH /robots/:id` | owner, or `bot.robot.manage` | `{ robot }`; new `limits` (including `allow`) re-send `config` to the connected device |
+| `PATCH /robots/:id` | owner, or `bot.robot.manage` | `{ robot }`; new `limits` (including `allow`) re-send `config` to the connected device. A `max_speed`/`max_turn` above the profile default applies only once the robot is commissioned, and never above the profile's ceiling; only the owner's own person token may raise one (`403 bot.forbidden` for a service, even with `bot.robot.manage` acting for the owner) |
+| `POST /robots/:id/commission` | **the owner, as a person**, body `{ confirm: true }` | `{ robot }` with `commissioned: { at, by }`; records who and when, one `robot.commission` audit row, re-sends `config`. `422 bot.confirm_required` without the confirm; `403 bot.forbidden` for an operator, an agent or any service token |
+| `POST /robots/:id/uncommission` | **the owner, as a person** | `{ robot }` with `commissioned: null`: back to the profile's defaults at once, `config` re-sent, one `robot.uncommission` audit row |
| `DELETE /robots/:id` | owner, or `bot.robot.manage` | `204` |
| `DELETE /robots/:id` | owner, or `bot.robot.manage` | `204` |
| `POST /robots/:id/pairing-code` | owner, or `bot.robot.manage` | `201 { code, expires_at, installer }`; with `BOT_PAIRING_AUTHORITY=network` `201 { code, expires_at, pairing_id, installer }` minted by Network for the robot's owner, or `503 bot.network_unavailable` |
| `POST /robots/:id/pairing-code` | owner, or `bot.robot.manage` | `201 { code, expires_at, installer }`; with `BOT_PAIRING_AUTHORITY=network` `201 { code, expires_at, pairing_id, installer }` minted by Network for the robot's owner, or `503 bot.network_unavailable` |
| `GET /robots/:id/operators` | member, or `bot.robot.read` | `{ operators[] }` |
| `GET /robots/:id/operators` | member, or `bot.robot.read` | `{ operators[] }` |
@@ -599,6 +601,24 @@ the values the gate and the device really use:
@@ -599,6 +601,24 @@ the values the gate and the device really use:
`effective_limits` is the same computation the command gate clamps with and the `config` frame sends, so
`effective_limits` is the same computation the command gate clamps with and the `config` frame sends, so
it cannot disagree with what the robot does. `limits_source.robot` lists only the owner's overrides.
it cannot disagree with what the robot does. `limits_source.robot` lists only the owner's overrides.
+### Commissioning a robot for higher limits
+
+A profile's `limits` are every robot's defaults and stay the clamp until its owner commissions it. A profile
+may also carry a hardware `ceiling` (`{ max_speed, max_turn }`; a profile without one has its `limits` as the
+ceiling). Commissioning is a separate, owner-only action:
+
+```
+POST /api/v1/robots/rob_…/commission
+{ "confirm": true }
+→ { "robot": { …, "commissioned": { "at": "2026-11-06T…", "by": "usr_…" } } }
+```
+
+With it, `PATCH /robots/:id` may set `max_speed` and `max_turn` above the profile default and up to the
+ceiling; without it they are clamped to the default exactly as before. `max_command_ms` is never raised (it
+bounds how long a command may run). Uncommissioning returns to the defaults at once and the device is sent
+the new config. The e-stop, roles, cooldowns and turn budget are unchanged, and operators and agents can
+never commission or raise a limit: the route answers `403 bot.forbidden` for anyone but the owner's own
+person token.
---
---
@@ -610,6 +630,7 @@ reason, latency, time), kept 30 days. Bot's own halt at the end of a queue turn
@@ -610,6 +630,7 @@ reason, latency, time), kept 30 days. Bot's own halt at the end of a queue turn
`operator_kind` `system`, no subject and `value` `{ reason: "turn_ended" | "turn_left" | "owner_takeover", turn_of }`; a
`operator_kind` `system`, no subject and `value` `{ reason: "turn_ended" | "turn_left" | "owner_takeover", turn_of }`; a
queue command refused because its turn ended while the gate decided is a `refused` row with reason `bot.turn_ended`. Changing a robot's streaming toggles writes one row per real change
queue command refused because its turn ended while the gate decided is a `refused` row with reason `bot.turn_ended`. Changing a robot's streaming toggles writes one row per real change
(`kind` `streaming.media` or `streaming.live`, `value` `{ on }`, role `owner`, result `ack`); an
(`kind` `streaming.media` or `streaming.live`, `value` `{ on }`, role `owner`, result `ack`); an
-idempotent repeat writes none. The outbox publishes `bot.robot.online`, `bot.robot.offline`,
+idempotent repeat writes none. Commissioning writes `robot.commission` and uncommissioning
+`robot.uncommission`, both as the owner. The outbox publishes `bot.robot.online`, `bot.robot.offline`,
`bot.estop.set`, `bot.estop.cleared` and `bot.command.refused` — small payloads of ids, kinds and
`bot.estop.set`, `bot.estop.cleared` and `bot.command.refused` — small payloads of ids, kinds and
results, never a credential, a pairing code, a publish key or a WHIP URL.
results, never a credential, a pairing code, a publish key or a WHIP URL.
new file mode 100644
new file mode 100644
@@ -0,0 +1,6 @@
@@ -0,0 +1,6 @@
+-- phase: expand
+-- Commissioned per-robot limits: the owner may raise max_speed/max_turn above the profile's default, up to
+-- the profile's hardware ceiling, only after commissioning that robot. Without commissioning the profile
+-- defaults still clamp every value; uncommissioning drops back to them at once.
+ALTER TABLE robots ADD COLUMN commissioned_at timestamptz;
+ALTER TABLE robots ADD COLUMN commissioned_by text; -- the owner's usr_ subject
@@ -17,6 +17,8 @@
@@ -17,6 +17,8 @@
* | POST /robots | bot.robot.manage | the owner (new robot + pairing code)|
* | POST /robots | bot.robot.manage | the owner (new robot + pairing code)|
* | GET /robots/:id | bot.robot.read | a member (owner/operator/viewer) |
* | GET /robots/:id | bot.robot.read | a member (owner/operator/viewer) |
* | PATCH /robots/:id | bot.robot.manage | owner |
* | PATCH /robots/:id | bot.robot.manage | owner |
+ * | POST /robots/:id/commission | bot.robot.manage | the owner, as a person, { confirm: true } |
+ * | POST /robots/:id/uncommission | bot.robot.manage | the owner, as a person |
* | DELETE /robots/:id | bot.robot.manage | owner |
* | DELETE /robots/:id | bot.robot.manage | owner |
* | POST /robots/:id/pairing-code | bot.robot.manage | owner |
* | POST /robots/:id/pairing-code | bot.robot.manage | owner |
* | GET|POST /robots/:id/operators | bot.robot.read/manage | owner (write) / member (read) |
* | GET|POST /robots/:id/operators | bot.robot.read/manage | owner (write) / member (read) |
@@ -275,10 +277,24 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
@@ -275,10 +277,24 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
}));
}));
r.patch('/robots/:id', manage, wrap(async (req, res) => {
r.patch('/robots/:id', manage, wrap(async (req, res) => {
await owner(req, req.params.id);
await owner(req, req.params.id);
- const updated = await domain.robots.update(req.params.id, { name: req.body?.name, access_policy: req.body?.access_policy, limits: req.body?.limits });
+ const updated = await domain.robots.update(req.params.id, { name: req.body?.name, access_policy: req.body?.access_policy, limits: req.body?.limits },
+ { subject: me(req), kind: req.principal.kind });
if (req.body?.limits !== undefined) await hub.refreshConfig(req.params.id); // the device enforces the new limits and allowlist at once
if (req.body?.limits !== undefined) await hub.refreshConfig(req.params.id); // the device enforces the new limits and allowlist at once
res.json({ robot: await presentRobot(updated) });
res.json({ robot: await presentRobot(updated) });
}));
}));
+ // Commissioning (safety): the owner, as a person, may raise this robot's max_speed/max_turn above the
+ // profile's defaults and up to its ceiling. The domain refuses a service even with bot.robot.manage, so
+ // no agent or app can ever widen a robot's limits. Uncommissioning returns to the defaults at once.
+ r.post('/robots/:id/commission', manage, wrap(async (req, res) => {
+ const robot = await owner(req, req.params.id);
+ const updated = await domain.robots.commission(robot.id, { subject: me(req), kind: req.principal.kind }, { confirm: req.body?.confirm });
+ res.json({ robot: domain.present.robot(updated) });
+ }));
+ r.post('/robots/:id/uncommission', manage, wrap(async (req, res) => {
+ const robot = await owner(req, req.params.id);
+ const updated = await domain.robots.uncommission(robot.id, { subject: me(req), kind: req.principal.kind });
+ res.json({ robot: domain.present.robot(updated) });
+ }));
r.delete('/robots/:id', manage, wrap(async (req, res) => {
r.delete('/robots/:id', manage, wrap(async (req, res) => {
await owner(req, req.params.id);
await owner(req, req.params.id);
await domain.robots.remove(req.params.id);
await domain.robots.remove(req.params.id);
@@ -84,6 +84,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -84,6 +84,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
const presentRobot = (r) => (r ? {
const presentRobot = (r) => (r ? {
id: r.id, name: r.name, profile_id: r.profile_id, profile_version: r.profile_version,
id: r.id, name: r.name, profile_id: r.profile_id, profile_version: r.profile_version,
access_policy: r.access_policy, limits: json(r.limits, {}), agent_control: !!r.agent_control,
access_policy: r.access_policy, limits: json(r.limits, {}), agent_control: !!r.agent_control,
+ commissioned: r.commissioned_at ? { at: iso(new Date(r.commissioned_at).getTime()), by: r.commissioned_by || null } : null,
estop: { latched: !!r.estop_latched, by: r.estop_by || null, at: r.estop_at ? iso(new Date(r.estop_at).getTime()) : null },
estop: { latched: !!r.estop_latched, by: r.estop_by || null, at: r.estop_at ? iso(new Date(r.estop_at).getTime()) : null },
created_at: iso(new Date(r.created_at).getTime()), updated_at: iso(new Date(r.updated_at).getTime()),
created_at: iso(new Date(r.created_at).getTime()), updated_at: iso(new Date(r.updated_at).getTime()),
} : null);
} : null);
@@ -185,17 +186,65 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -185,17 +186,65 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
}
}
return out;
return out;
}
}
- async function updateRobot(id, { name, access_policy, limits }) {
+ async function updateRobot(id, { name, access_policy, limits }, actor = null) {
const sets = []; const args = [id];
const sets = []; const args = [id];
if (name !== undefined) { const n = text(name, 'name', 80); if (!n) fail(422, 'bot.invalid_input', 'name cannot be empty'); args.push(n); sets.push(`name = $${args.length}`); }
if (name !== undefined) { const n = text(name, 'name', 80); if (!n) fail(422, 'bot.invalid_input', 'name cannot be empty'); args.push(n); sets.push(`name = $${args.length}`); }
if (access_policy !== undefined) { if (!['private', 'invite', 'queue'].includes(access_policy)) fail(422, 'bot.invalid_policy', 'bad access_policy'); args.push(access_policy); sets.push(`access_policy = $${args.length}`); }
if (access_policy !== undefined) { if (!['private', 'invite', 'queue'].includes(access_policy)) fail(422, 'bot.invalid_policy', 'bad access_policy'); args.push(access_policy); sets.push(`access_policy = $${args.length}`); }
- if (limits !== undefined) { args.push(JSON.stringify(cleanLimits(limits))); sets.push(`limits = $${args.length}::jsonb`); }
+ if (limits !== undefined) {
+ // Raising a drive limit above the profile default is the owner's own act, never an operator's, an
+ // agent's or an app token's — commissioning is only the first half. Lower values stay settable.
+ const robot = await getRobot(id);
+ const row = robot ? await getProfile(db, robot.profile_id, robot.profile_version) : null;
+ const pl = (row && row.profile && row.profile.limits) || {};
+ const aboveDefault = (limits.max_speed != null && Number(limits.max_speed) > (pl.max_speed != null ? pl.max_speed : 1))
+ || (limits.max_turn != null && Number(limits.max_turn) > (pl.max_turn != null ? pl.max_turn : 1));
+ if (aboveDefault && (!actor || actor.kind !== 'user' || actor.subject !== robot.owner_subject)) {
+ fail(403, 'bot.forbidden', 'only the owner may raise a robot\'s limits above the profile default');
+ }
+ args.push(JSON.stringify(cleanLimits(limits))); sets.push(`limits = $${args.length}::jsonb`);
+ }
if (!sets.length) return getRobot(id);
if (!sets.length) return getRobot(id);
args.push(iso(now()));
args.push(iso(now()));
sets.push(`updated_at = $${args.length}`);
sets.push(`updated_at = $${args.length}`);
await db.query(`UPDATE robots SET ${sets.join(', ')} WHERE id = $1`, args);
await db.query(`UPDATE robots SET ${sets.join(', ')} WHERE id = $1`, args);
return getRobot(id);
return getRobot(id);
}
}
+ /**
+ * Commission a robot (safety-relevant): only its owner, as a person, and only with an explicit confirm.
+ * It records who and when, and lets effectiveLimits accept max_speed/max_turn above the profile's
+ * defaults and up to its ceiling. Operators, agents and services can never do this; the device gets the
+ * new config at once. Idempotent: commissioning a commissioned robot writes nothing new.
+ */
+ async function commissionRobot(robotId, actor, { confirm } = {}) {
+ const robot = await getRobot(robotId);
+ if (!robot) fail(404, 'bot.robot_not_found', 'no such robot');
+ if (!actor || actor.kind !== 'user' || actor.subject !== robot.owner_subject) {
+ fail(403, 'bot.forbidden', 'only the owner may commission this robot');
+ }
+ if (confirm !== true) fail(422, 'bot.confirm_required', 'commissioning needs { confirm: true }');
+ if (robot.commissioned_at) return robot;
+ const at = iso(now());
+ await db.query('UPDATE robots SET commissioned_at = $2, commissioned_by = $3, updated_at = $2 WHERE id = $1', [robotId, at, actor.subject]);
+ await auditCommand({ robotId, subject: actor.subject, operatorKind: actor.kind, role: 'owner', kind: 'robot.commission', result: 'ack' });
+ if (link && link.refreshConfig) await link.refreshConfig(robotId);
+ if (link && link.broadcast) link.broadcast(robotId);
+ return getRobot(robotId);
+ }
+ /** Drop a robot back to its profile's defaults at once (the owner only, as a person). */
+ async function uncommissionRobot(robotId, actor) {
+ const robot = await getRobot(robotId);
+ if (!robot) fail(404, 'bot.robot_not_found', 'no such robot');
+ if (!actor || actor.kind !== 'user' || actor.subject !== robot.owner_subject) {
+ fail(403, 'bot.forbidden', 'only the owner may commission this robot');
+ }
+ if (!robot.commissioned_at) return robot;
+ const at = iso(now());
+ await db.query('UPDATE robots SET commissioned_at = NULL, commissioned_by = NULL, updated_at = $2 WHERE id = $1', [robotId, at]);
+ await auditCommand({ robotId, subject: actor.subject, operatorKind: actor.kind, role: 'owner', kind: 'robot.uncommission', result: 'ack' });
+ if (link && link.refreshConfig) await link.refreshConfig(robotId);
+ if (link && link.broadcast) link.broadcast(robotId);
+ return getRobot(robotId);
+ }
async function localProfile(robotId) {
async function localProfile(robotId) {
return db.maybe('SELECT source_profile_id, profile FROM robot_profiles WHERE robot_id = $1', [robotId]);
return db.maybe('SELECT source_profile_id, profile FROM robot_profiles WHERE robot_id = $1', [robotId]);
}
}
@@ -1200,15 +1249,29 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -1200,15 +1249,29 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
}
}
// ── The control gate (ADR-043 decisions 5, 6, 8) ──────────────────────────────────────────────
// ── The control gate (ADR-043 decisions 5, 6, 8) ──────────────────────────────────────────────
- /** The owner's limits clamped by the profile's (a profile missing one falls back to Bot's defaults). */
+ /**
+ * The one place the effective limits are decided: the command gate clamps with it and the device's
+ * `config` frame sends it, so they cannot disagree.
+ *
+ * The profile's `limits` are the defaults for everyone. Its `ceiling` (defaulting to the limits) is what
+ * a *commissioned* robot's owner may raise max_speed/max_turn to; without commissioning the defaults
+ * clamp exactly as they always did. max_command_ms is never raised: it bounds how long a command may run,
+ * so the profile's value is its ceiling too.
+ */
const effectiveLimits = (robot, profile) => {
const effectiveLimits = (robot, profile) => {
const rl = json(robot.limits, {});
const rl = json(robot.limits, {});
const pl = (profile && profile.limits) || {};
const pl = (profile && profile.limits) || {};
+ const pc = (profile && profile.ceiling) || {};
const pSpeed = pl.max_speed != null ? pl.max_speed : 1;
const pSpeed = pl.max_speed != null ? pl.max_speed : 1;
const pTurn = pl.max_turn != null ? pl.max_turn : 1;
const pTurn = pl.max_turn != null ? pl.max_turn : 1;
const pMs = pl.max_command_ms != null ? pl.max_command_ms : config.control.maxCommandMs;
const pMs = pl.max_command_ms != null ? pl.max_command_ms : config.control.maxCommandMs;
- const maxSpeed = rl.max_speed != null ? Math.min(rl.max_speed, pSpeed) : pSpeed;
- const maxTurn = rl.max_turn != null ? Math.min(rl.max_turn, pTurn) : pTurn;
+ const cSpeed = pc.max_speed != null ? Math.max(pc.max_speed, pSpeed) : pSpeed;
+ const cTurn = pc.max_turn != null ? Math.max(pc.max_turn, pTurn) : pTurn;
+ const commissioned = !!robot.commissioned_at;
+ const speedCap = commissioned ? cSpeed : pSpeed;
+ const turnCap = commissioned ? cTurn : pTurn;
+ const maxSpeed = rl.max_speed != null ? Math.min(rl.max_speed, speedCap) : pSpeed;
+ const maxTurn = rl.max_turn != null ? Math.min(rl.max_turn, turnCap) : pTurn;
const maxCommandMs = rl.max_command_ms != null ? Math.min(rl.max_command_ms, pMs) : pMs;
const maxCommandMs = rl.max_command_ms != null ? Math.min(rl.max_command_ms, pMs) : pMs;
const cooldownMs = rl.cooldown_ms != null ? rl.cooldown_ms : config.control.cooldownMs;
const cooldownMs = rl.cooldown_ms != null ? rl.cooldown_ms : config.control.cooldownMs;
return { maxSpeed, maxTurn, maxCommandMs, cooldownMs };
return { maxSpeed, maxTurn, maxCommandMs, cooldownMs };
@@ -1423,7 +1486,8 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -1423,7 +1486,8 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
return {
return {
db, now, config, log, outbox, link,
db, now, config, log, outbox, link,
present: { robot: presentRobot, device: presentDevice, video: presentVideo },
present: { robot: presentRobot, device: presentDevice, video: presentVideo },
- robots: { create: createRobot, list: listRobots, get: getRobot, update: updateRobot, setEmbedPublic, setListed, listListed, setAgentControl, remove: removeRobot,
+ robots: { create: createRobot, list: listRobots, get: getRobot, update: updateRobot, commission: commissionRobot, uncommission: uncommissionRobot,
+ 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, onPaired },
pairing: { create: createPairingCode, redeem, prune: prunePairingCodes, installerCommand, driverForProfile, whipUrl, onPaired },
@@ -16,7 +16,8 @@
@@ -16,7 +16,8 @@
* commands: { drive: { axes: { throttle: [-1, 1], … } }, … }, // what the device accepts (COMMAND_SCHEMAS)
* commands: { drive: { axes: { throttle: [-1, 1], … } }, … }, // what the device accepts (COMMAND_SCHEMAS)
* widgets: [{ type: "drive", capability: …, label: …, command: { kind: "drive" } }], // types in WIDGETS
* widgets: [{ type: "drive", capability: …, label: …, command: { kind: "drive" } }], // types in WIDGETS
* camera: { transport: "whip|onvif|rtsp", resolution },
* camera: { transport: "whip|onvif|rtsp", resolution },
- * limits: { max_speed, max_turn, max_command_ms, heartbeat_ms } }
+ * limits: { max_speed, max_turn, max_command_ms, heartbeat_ms },
+ * ceiling: { max_speed, max_turn } } // optional: what a commissioned owner may raise to
*
*
* `commands` is the contract with the device's plugin: the gate allows only the kinds declared there
* `commands` is the contract with the device's plugin: the gate allows only the kinds declared there
* (plus `halt`, which every profile takes) and builds every value from it, so a panel control can only
* (plus `halt`, which every profile takes) and builds every value from it, so a panel control can only
@@ -203,12 +204,24 @@ function validateProfile(p) {
@@ -203,12 +204,24 @@ function validateProfile(p) {
};
};
if (!(limits.max_speed > 0) || !(limits.max_turn > 0)) bad(`${p.id}: max_speed and max_turn must be positive`);
if (!(limits.max_speed > 0) || !(limits.max_turn > 0)) bad(`${p.id}: max_speed and max_turn must be positive`);
if (!(limits.max_command_ms > 0) || !(limits.heartbeat_ms > 0)) bad(`${p.id}: max_command_ms and heartbeat_ms must be positive`);
if (!(limits.max_command_ms > 0) || !(limits.heartbeat_ms > 0)) bad(`${p.id}: max_command_ms and heartbeat_ms must be positive`);
- const contract = validate('bot.robot-profile@1', p);
+ // A hardware ceiling is what a commissioned owner may raise the two drive limits to. It defaults to the
+ // limits themselves, so a profile without one keeps today's behaviour exactly. The pinned contract does
+ // not know the field yet, so the contract check runs on the profile without it.
+ const c = p.ceiling && typeof p.ceiling === 'object' ? p.ceiling : {};
+ const ceiling = {
+ max_speed: number(c.max_speed) != null ? number(c.max_speed) : limits.max_speed,
+ max_turn: number(c.max_turn) != null ? number(c.max_turn) : limits.max_turn,
+ };
+ if (!(ceiling.max_speed > 0) || !(ceiling.max_turn > 0)) bad(`${p.id}: ceiling.max_speed and ceiling.max_turn must be positive`);
+ if (ceiling.max_speed < limits.max_speed) bad(`${p.id}: ceiling.max_speed is below limits.max_speed`);
+ if (ceiling.max_turn < limits.max_turn) bad(`${p.id}: ceiling.max_turn is below limits.max_turn`);
+ const { ceiling: _ceiling, ...contracted } = p;
+ const contract = validate('bot.robot-profile@1', contracted);
if (!contract.valid) bad(`bot.robot-profile@1: ${JSON.stringify(contract.errors)}`);
if (!contract.valid) bad(`bot.robot-profile@1: ${JSON.stringify(contract.errors)}`);
return {
return {
id: p.id, version: p.version, name: p.name.trim(), vendor: p.vendor || null, kind: p.kind || null,
id: p.id, version: p.version, name: p.name.trim(), vendor: p.vendor || null, kind: p.kind || null,
description: p.description || null, capabilities: [...p.capabilities], variants: p.variants || null,
description: p.description || null, capabilities: [...p.capabilities], variants: p.variants || null,
- mapping: p.mapping, commands, widgets, camera: p.camera || null, limits,
+ mapping: p.mapping, commands, widgets, camera: p.camera || null, limits, ceiling,
};
};
}
}
@@ -220,7 +233,8 @@ function validateProfile(p) {
@@ -220,7 +233,8 @@ function validateProfile(p) {
*/
*/
function checkProfile(value) {
function checkProfile(value) {
if (!isObject(value)) return { valid: false, problems: ['a profile must be a JSON object'] };
if (!isObject(value)) return { valid: false, problems: ['a profile must be a JSON object'] };
- const contract = validate('bot.robot-profile@1', value);
+ const { ceiling: _ceiling, ...contracted } = value;
+ const contract = validate('bot.robot-profile@1', contracted);
if (!contract.valid) {
if (!contract.valid) {
return { valid: false, problems: (contract.errors || []).map((e) => {
return { valid: false, problems: (contract.errors || []).map((e) => {
const at = String(e.path || '').replace(/^\//, '').replace(/\//g, '.');
const at = String(e.path || '').replace(/^\//, '').replace(/\//g, '.');
@@ -17,5 +17,6 @@
@@ -17,5 +17,6 @@
{ "type": "battery", "capability": "battery", "label": "Battery", "order": 3 },
{ "type": "battery", "capability": "battery", "label": "Battery", "order": 3 },
{ "type": "latency", "label": "Latency", "order": 4 }
{ "type": "latency", "label": "Latency", "order": 4 }
],
],
- "limits": { "max_speed": 0.5, "max_turn": 0.6, "max_command_ms": 300, "heartbeat_ms": 1000 }
+ "limits": { "max_speed": 0.5, "max_turn": 0.6, "max_command_ms": 300, "heartbeat_ms": 1000 },
+ "ceiling": { "max_speed": 1, "max_turn": 1 }
}
}
@@ -266,6 +266,7 @@ function renderProfilesPage({ config, source = '', result = null } = {}) {
@@ -266,6 +266,7 @@ function renderProfilesPage({ config, source = '', result = null } = {}) {
<li><code>widgets</code> — the panel, in order. Each is <code>{ type, capability?, label?, command? }</code>; types are <code>drive</code>, <code>pan-tilt</code>, <code>servo</code>, <code>lights</code>, <code>horn</code>, <code>speaker</code>, <code>display</code>, <code>telemetry</code>, <code>battery</code>, <code>latency</code>, <code>ptz</code>, <code>camera</code>, <code>head</code>, <code>lift</code>, <code>buttons</code>, <code>video_click</code>. A widget that sends commands names them in <code>command</code>, checked against <code>commands</code> at load.</li>
<li><code>widgets</code> — the panel, in order. Each is <code>{ type, capability?, label?, command? }</code>; types are <code>drive</code>, <code>pan-tilt</code>, <code>servo</code>, <code>lights</code>, <code>horn</code>, <code>speaker</code>, <code>display</code>, <code>telemetry</code>, <code>battery</code>, <code>latency</code>, <code>ptz</code>, <code>camera</code>, <code>head</code>, <code>lift</code>, <code>buttons</code>, <code>video_click</code>. A widget that sends commands names them in <code>command</code>, checked against <code>commands</code> at load.</li>
<li><code>camera</code> — optional: <code>{ transport: "whip"|"onvif"|"rtsp", resolution }</code>.</li>
<li><code>camera</code> — optional: <code>{ transport: "whip"|"onvif"|"rtsp", resolution }</code>.</li>
<li><code>limits</code> — optional, with defaults: <code>max_speed</code> and <code>max_turn</code> (1), <code>max_command_ms</code> (300), <code>heartbeat_ms</code> (1000). Positive; the stricter of these and the server's own limits applies.</li>
<li><code>limits</code> — optional, with defaults: <code>max_speed</code> and <code>max_turn</code> (1), <code>max_command_ms</code> (300), <code>heartbeat_ms</code> (1000). Positive; the stricter of these and the server's own limits applies.</li>
+<li><code>ceiling</code> — optional: <code>{ max_speed, max_turn }</code>, what a robot's owner may raise those two to after commissioning it. It defaults to <code>limits</code>, so a profile without one never goes above them.</li>
</ul>
</ul>
<p class="muted">The schema is <code>bot.robot-profile@1</code>; capability and widget names are open strings checked against the registries in <code>server/profiles/index.js</code>, so a new one is one line there, never a schema change.</p></section>
<p class="muted">The schema is <code>bot.robot-profile@1</code>; capability and widget names are open strings checked against the registries in <code>server/profiles/index.js</code>, so a new one is one line there, never a schema change.</p></section>
@@ -302,6 +303,13 @@ app.post('/robot-events', express.text({ type: 'application/json' }), (req, res)
@@ -302,6 +303,13 @@ app.post('/robot-events', express.text({ type: 'application/json' }), (req, res)
const ACCOUNT_CALL = `curl https://openvibe.bot/api/v1/me \\
const ACCOUNT_CALL = `curl https://openvibe.bot/api/v1/me \\
-H "Authorization: Bearer $OPENVIBE_TOKEN"
-H "Authorization: Bearer $OPENVIBE_TOKEN"
# → {"subject": "usr_…", "username": "alex", "display_name": "Alex"}`;
# → {"subject": "usr_…", "username": "alex", "display_name": "Alex"}`;
+const COMMISSION_CALL = `curl -X POST https://openvibe.bot/api/v1/robots/rob_…/commission \\
+ -H "Authorization: Bearer $OPENVIBE_TOKEN" -H "Content-Type: application/json" \\
+ -d '{"confirm": true}'
+# → {"robot": {"id": "rob_…", "commissioned": {"at": "…", "by": "usr_…"}, …}}
+# Undo it, back to the profile's defaults at once:
+curl -X POST https://openvibe.bot/api/v1/robots/rob_…/uncommission \\
+ -H "Authorization: Bearer $OPENVIBE_TOKEN"`;
const STEPS_CALL = `curl -X POST https://openvibe.bot/api/v1/robots/rob_…/steps \\
const STEPS_CALL = `curl -X POST https://openvibe.bot/api/v1/robots/rob_…/steps \\
-H "Authorization: Bearer $OPENVIBE_TOKEN" -H "Content-Type: application/json" \\
-H "Authorization: Bearer $OPENVIBE_TOKEN" -H "Content-Type: application/json" \\
@@ -350,6 +358,10 @@ ${code(STEPS_CALL)}
@@ -350,6 +358,10 @@ ${code(STEPS_CALL)}
<p>Every person is a <code>usr_…</code> subject, and it is what an owner names when they add an operator or hand a robot to a service. Your own token answers it:</p>
<p>Every person is a <code>usr_…</code> subject, and it is what an owner names when they add an operator or hand a robot to a service. Your own token answers it:</p>
${code(ACCOUNT_CALL)}
${code(ACCOUNT_CALL)}
<p>A robot's answer carries its owner and the limits it really enforces, so an integrator never has to read <code>limits: {}</code> as "no limits": <code>owner_subject</code>, <code>effective_limits</code> (<code>max_speed</code>, <code>max_turn</code>, <code>max_command_ms</code>, <code>heartbeat_ms</code>) and <code>limits_source</code> (<code>profile</code> and <code>robot</code>). The same ID is on <a href="https://openvibe.network/my">openvibe.network/my</a>, and Network answers it at <code>GET https://openvibe.network/api/auth/me</code> as <code>user.subject_id</code>.</p></section>
<p>A robot's answer carries its owner and the limits it really enforces, so an integrator never has to read <code>limits: {}</code> as "no limits": <code>owner_subject</code>, <code>effective_limits</code> (<code>max_speed</code>, <code>max_turn</code>, <code>max_command_ms</code>, <code>heartbeat_ms</code>) and <code>limits_source</code> (<code>profile</code> and <code>robot</code>). The same ID is on <a href="https://openvibe.network/my">openvibe.network/my</a>, and Network answers it at <code>GET https://openvibe.network/api/auth/me</code> as <code>user.subject_id</code>.</p></section>
+<section class="card" aria-labelledby="commission-h"><h2 id="commission-h">Higher limits, commissioned</h2>
+<p>A profile's <code>limits</code> are every robot's defaults and clamp its commands. If the hardware can do more, the profile names a <code>ceiling</code>, and the robot's owner may — only after commissioning that robot — set its own <code>max_speed</code> and <code>max_turn</code> above the default and up to that ceiling. Without commissioning they are clamped to the default exactly as before, and <code>max_command_ms</code> is never raised. Commissioning records who and when in the audit; uncommissioning returns the robot to the defaults at once. Operators, apps and agents can never commission a robot or raise a limit.</p>
+${code(COMMISSION_CALL)}
+<p class="muted">On the panel it is under <b>Commissioned limits</b>. The same action is <code>POST /api/v1/robots/:id/commission</code> (body <code>{ "confirm": true }</code>) and <code>POST /api/v1/robots/:id/uncommission</code>.</p></section>
`;
`;
return docsPage({ config, path: '/docs/automation', title: 'Routines, webhooks and your agent', description: 'Make a robot run steps on a schedule, send its events to your server signed, and let your OpenVibe.Actor agent drive it, all through the same gate.', content });
return docsPage({ config, path: '/docs/automation', title: 'Routines, webhooks and your agent', description: 'Make a robot run steps on a schedule, send its events to your server signed, and let your OpenVibe.Actor agent drive it, all through the same gate.', content });
}
}
@@ -511,6 +511,39 @@ ${row('Longest command', 'max_command_ms', `${effective.maxCommandMs} ms`)}
@@ -511,6 +511,39 @@ ${row('Longest command', 'max_command_ms', `${effective.maxCommandMs} ms`)}
</section>`;
</section>`;
}
}
+
+/**
+ * Commissioning (owner only): raise this robot's max_speed/max_turn above the profile's defaults, up to its
+ * ceiling, and drop back to the defaults at once. One explicit form each way; nothing here applies to an
+ * operator or an agent.
+ */
+function commissionForm(robot, profile) {
+ const limits = profile.limits || {};
+ const ceiling = profile.ceiling || limits;
+ const at = robot.commissioned || null;
+ const own = robot.limits || {};
+ const allow = `up to ${esc(ceiling.max_speed)} speed and ${esc(ceiling.max_turn)} turn`;
+ const when = (iso) => { const d = new Date(iso); return isNaN(d) ? esc(iso) : esc(d.toISOString().slice(0, 16).replace('T', ' ')) + ' UTC'; };
+ if (at) {
+ // Commissioned: the owner sets this robot's own drive limits here (or with PATCH /api/v1/robots/:id).
+ const field = (name, label, value, cap) => `<label class="setting-field"><span>${label}</span><input type="number" name="${name}" min="0.05" max="${esc(cap)}" step="0.05" value="${esc(value)}" required inputmode="decimal"><small>${esc(limits[name])} by default, at most ${esc(cap)}</small></label>`;
+ return `<section class="setting-card" id="commission" aria-labelledby="commission-h"><h2 id="commission-h">Commissioned limits</h2>
+<p class="setting-ok" role="status">Commissioned ${when(at.at)}. This robot may use your own speed and turn ${allow}.</p>
+<form method="post" action="/robots/${esc(robot.id)}/limits" class="setting-form">
+${field('max_speed', 'Top speed', own.max_speed != null ? own.max_speed : limits.max_speed, ceiling.max_speed)}
+${field('max_turn', 'Top turn', own.max_turn != null ? own.max_turn : limits.max_turn, ceiling.max_turn)}
+<button type="submit" class="primary">Save the limits</button></form>
+<p class="setting-note">The robot gets them at once. The e-stop, cooldowns and command length (${esc(limits.max_command_ms)} ms) stay as they are.</p>
+<form method="post" action="/robots/${esc(robot.id)}/uncommission"><button type="submit" class="quiet">Return to the profile defaults (${esc(limits.max_speed)} speed, ${esc(limits.max_turn)} turn)</button></form></section>`;
+ }
+ // Not commissioned: one deliberate act, with the owner saying what they checked.
+ return `<section class="setting-card" id="commission" aria-labelledby="commission-h"><h2 id="commission-h">Commissioned limits</h2>
+<p class="setting-note">The profile's speed (${esc(limits.max_speed)}) and turn (${esc(limits.max_turn)}) limits clamp every command. Commissioning this robot lets you set its own higher values ${allow}. It records that you did it and when, and it is yours alone: operators and your agent can never do it.</p>
+<form method="post" action="/robots/${esc(robot.id)}/commission" class="setting-form">
+<label class="setting-check"><input type="checkbox" name="confirm" value="on" required> I have checked this robot can move faster safely: there is clear space around it, and I can reach its e-stop.</label>
+<button type="submit" class="primary">Commission this robot</button></form></section>`;
+}
+
function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs = 150, mode = 'page', signedIn = true, streaming = null, people = null, signaling_url = null, profileForm = {}, tryIt = false, automation = null, parentOrigins = [], liveUrl = null, limitsInfo = null }) {
function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs = 150, mode = 'page', signedIn = true, streaming = null, people = null, signaling_url = null, profileForm = {}, tryIt = false, automation = null, parentOrigins = [], liveUrl = null, limitsInfo = null }) {
const embed = mode === 'embed';
const embed = mode === 'embed';
const latched = !!(robot.estop && robot.estop.latched);
const latched = !!(robot.estop && robot.estop.latched);
@@ -538,7 +571,7 @@ ${embed && role === 'watcher' && !signedIn ? `<p class="embed-sign-in" data-driv
@@ -538,7 +571,7 @@ ${embed && role === 'watcher' && !signedIn ? `<p class="embed-sign-in" data-driv
<div class="widgets">
<div class="widgets">
${widgets.join('\n')}
${widgets.join('\n')}
</div>
</div>
-${tryIt ? tryOutro(profile) : ''}${role === 'owner' && !embed && !tryIt ? `<div class="owner-settings">${limitsCard(robot, limitsInfo, profile)}${onStreamCard(robot, { liveUrl })}${localProfileForm(robot, profile, profileForm)}${automation ? routinesCard(robot, profile, automation) + webhooksCard(robot, automation) : ''}${agentForm(robot)}${listingForm(robot)}${embedForm(robot)}${streamingForm(robot, streaming)}${people ? peopleCard(robot, people) : ''}</div>` : ''}
+${tryIt ? tryOutro(profile) : ''}${role === 'owner' && !embed && !tryIt ? `<div class="owner-settings">${limitsCard(robot, limitsInfo, profile)}${onStreamCard(robot, { liveUrl })}${localProfileForm(robot, profile, profileForm)}${commissionForm(robot, profile)}${automation ? routinesCard(robot, profile, automation) + webhooksCard(robot, automation) : ''}${agentForm(robot)}${listingForm(robot)}${embedForm(robot)}${streamingForm(robot, streaming)}${people ? peopleCard(robot, people) : ''}</div>` : ''}
</main>`;
</main>`;
return page(tryIt ? 'Try the panel with a simulated rover' : robot.name, body, {
return page(tryIt ? 'Try the panel with a simulated rover' : robot.name, body, {
scripts: panelScripts(profile, tryIt), bodyClass: embed ? 'embed-page' : 'panel-page',
scripts: panelScripts(profile, tryIt), bodyClass: embed ? 'embed-page' : 'panel-page',
@@ -17,6 +17,8 @@
@@ -17,6 +17,8 @@
* POST /robots/:id/embed the owner only: allow (embed_public=on) or stop (off) anonymous read-only embedding → 303 to its panel
* POST /robots/:id/embed the owner only: allow (embed_public=on) or stop (off) anonymous read-only embedding → 303 to its panel
* POST /robots/:id/on-stream the owner only: show the panel where it is embedded, then 303 to their Live channel's bind prompt
* POST /robots/:id/on-stream the owner only: show the panel where it is embedded, then 303 to their Live channel's bind prompt
* POST /robots/:id/access the owner only: who may drive (private | invite | queue) → 303 to its panel
* POST /robots/:id/access the owner only: who may drive (private | invite | queue) → 303 to its panel
+ * POST /robots/:id/commission, /uncommission the owner only: per-robot limits above the profile default → 303
+ * POST /robots/:id/limits the owner only, commissioned: this robot's top speed and turn → 303
* POST /robots/:id/operators the owner only: add a person by @username with a role → 303 to its panel
* POST /robots/:id/operators the owner only: add a person by @username with a role → 303 to its panel
* POST /robots/:id/operators/:subject/remove the owner only: remove a person (never the owner row) → 303
* POST /robots/:id/operators/:subject/remove the owner only: remove a person (never the owner row) → 303
* POST /robots/:id/queue/leave any signed-in person: leave the robot's turn queue → 303
* POST /robots/:id/queue/leave any signed-in person: leave the robot's turn queue → 303
@@ -113,7 +115,7 @@ const LLMS_PAGES = [
@@ -113,7 +115,7 @@ const LLMS_PAGES = [
{
{
path: '/docs/automation', title: 'Routines, webhooks and your agent',
path: '/docs/automation', title: 'Routines, webhooks and your agent',
note: 'Steps on a schedule, signed webhooks for a robot\'s events, and the switch that lets your OpenVibe.Actor agent drive.',
note: 'Steps on a schedule, signed webhooks for a robot\'s events, and the switch that lets your OpenVibe.Actor agent drive.',
- text: 'How a robot on OpenVibe.Bot moves without anyone at the controls, through the same gate as a person (limits, cooldowns, e-stop, audit). GET /api/v1/me answers a person\'s account ID (subject, username, display_name); a robot\'s answer carries owner_subject, effective_limits (max_speed, max_turn, max_command_ms, heartbeat_ms) and limits_source. Steps are plain lines checked against the robot\'s profile: forward, back, left and right for seconds at a speed, stop, wait, say, face, show, an actuator or camera axis with a value and a hold time, and press for a button; held steps are re-sent inside the deadman window and a refused step stops the rest with its reason. Routines run steps as the owner on demand, every N minutes (at least 5), daily at a UTC time on chosen weekdays, or each time the robot comes online; one runs at a time; 60 steps, 60 seconds a step, 10 minutes in all. Webhooks send a robot\'s bot.robot.online, bot.robot.offline, bot.estop.set, bot.estop.cleared and bot.command.refused events to up to five https addresses, each with its own whsec_ secret shown once, signed like OpenVibe.Events deliveries (X-OpenVibe-Timestamp, X-OpenVibe-Signature-V2, checked with verifyDeliveryV2 from openvibe-sdk/events), to public addresses only, retried after 30 seconds, 2, 10, 30 and 120 minutes. The Your agent switch, off until the owner turns it on, lets the owner\'s OpenVibe.Actor agent see their robots and run steps on that one as the owner, never for anyone else; a steps call on Bot\'s API runs up to 20 steps and 30 seconds and answers done, or stopped with the reason.',
+ text: 'How a robot on OpenVibe.Bot moves without anyone at the controls, through the same gate as a person (limits, cooldowns, e-stop, audit). GET /api/v1/me answers a person\'s account ID (subject, username, display_name); a robot\'s answer carries owner_subject, effective_limits (max_speed, max_turn, max_command_ms, heartbeat_ms) and limits_source. POST /api/v1/robots/:id/commission lets its owner raise max_speed and max_turn above the profile default, up to the profile ceiling, and POST /robots/:id/uncommission returns to the defaults; only the owner as a person may do either. Steps are plain lines checked against the robot\'s profile: forward, back, left and right for seconds at a speed, stop, wait, say, face, show, an actuator or camera axis with a value and a hold time, and press for a button; held steps are re-sent inside the deadman window and a refused step stops the rest with its reason. Routines run steps as the owner on demand, every N minutes (at least 5), daily at a UTC time on chosen weekdays, or each time the robot comes online; one runs at a time; 60 steps, 60 seconds a step, 10 minutes in all. Webhooks send a robot\'s bot.robot.online, bot.robot.offline, bot.estop.set, bot.estop.cleared and bot.command.refused events to up to five https addresses, each with its own whsec_ secret shown once, signed like OpenVibe.Events deliveries (X-OpenVibe-Timestamp, X-OpenVibe-Signature-V2, checked with verifyDeliveryV2 from openvibe-sdk/events), to public addresses only, retried after 30 seconds, 2, 10, 30 and 120 minutes. The Your agent switch, off until the owner turns it on, lets the owner\'s OpenVibe.Actor agent see their robots and run steps on that one as the owner, never for anyone else; a steps call on Bot\'s API runs up to 20 steps and 30 seconds and answers done, or stopped with the reason.',
},
},
{
{
path: '/docs/computers', title: 'Your computer for your agents',
path: '/docs/computers', title: 'Your computer for your agents',
@@ -989,6 +991,48 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
@@ -989,6 +991,48 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
res.redirect(303, `/panel/${robot.id}#on-stream`);
res.redirect(303, `/panel/${robot.id}#on-stream`);
}));
}));
+ // Commissioning, on the owner's page: one form each way, and the same domain action /api/v1 calls. Only the
+ // owner as a person may do it; the device's config is re-sent by the domain at once.
+ r.post('/robots/:id/commission', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {
+ const me = requireUser(req);
+ if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');
+ const robot = await domain.robots.get(req.params.id);
+ if (!robot) throw new BotError(404, 'bot.robot_not_found', 'no such robot');
+ if (robot.owner_subject !== me.subject) throw new BotError(403, 'bot.forbidden', 'only the owner may commission this robot');
+ // The owner ticks what they checked; a form without it commissions nothing.
+ if (!req.body || req.body.confirm !== 'on') throw new BotError(422, 'bot.confirm_required', 'tick the box to say the robot can move faster safely');
+ await domain.robots.commission(robot.id, { subject: me.subject, kind: 'user' }, { confirm: true });
+ if (hub) hub.broadcast(robot.id);
+ return res.redirect(303, `/panel/${robot.id}#commission`);
+ }));
+ // A commissioned robot's own drive limits, from the owner's page: the same domain update /api/v1 PATCH makes
+ // (the raise guard and the ceiling clamp are there), then the device gets the new config.
+ r.post('/robots/:id/limits', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {
+ const me = requireUser(req);
+ if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');
+ const robot = await domain.robots.get(req.params.id);
+ if (!robot) throw new BotError(404, 'bot.robot_not_found', 'no such robot');
+ if (robot.owner_subject !== me.subject) throw new BotError(403, 'bot.forbidden', 'only the owner may set this robot\'s limits');
+ const num = (v) => { const n = Number(v); return Number.isFinite(n) && n > 0 && n <= 1 ? n : null; };
+ const speed = num(req.body && req.body.max_speed), turn = num(req.body && req.body.max_turn);
+ if (speed == null || turn == null) throw new BotError(422, 'bot.invalid_input', 'speed and turn are numbers above 0, at most 1');
+ const stored = typeof robot.limits === 'string' ? JSON.parse(robot.limits || '{}') : (robot.limits || {});
+ const limits = { ...stored, max_speed: speed, max_turn: turn };
+ await domain.robots.update(robot.id, { limits }, { subject: me.subject, kind: 'user' });
+ if (hub) { await hub.refreshConfig(robot.id); hub.broadcast(robot.id); }
+ return res.redirect(303, `/panel/${robot.id}#commission`);
+ }));
+ r.post('/robots/:id/uncommission', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {
+ const me = requireUser(req);
+ if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');
+ const robot = await domain.robots.get(req.params.id);
+ if (!robot) throw new BotError(404, 'bot.robot_not_found', 'no such robot');
+ if (robot.owner_subject !== me.subject) throw new BotError(403, 'bot.forbidden', 'only the owner may commission this robot');
+ await domain.robots.uncommission(robot.id, { subject: me.subject, kind: 'user' });
+ if (hub) hub.broadcast(robot.id);
+ return res.redirect(303, `/panel/${robot.id}#commission`);
+ }));
+
r.post('/robots/:id/embed', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {
r.post('/robots/:id/embed', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {
const me = requireUser(req);
const me = requireUser(req);
if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');
if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');
new file mode 100644
new file mode 100644
@@ -0,0 +1,145 @@
@@ -0,0 +1,145 @@
+'use strict';
+// Commissioned per-robot limits (safety): the owner, as a person, may raise max_speed/max_turn above the
+// profile's defaults and up to its ceiling only after commissioning; everyone else never can; the device
+// receives the same effective values the gate uses.
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/app');
+const { loadProfiles } = require('../server/profiles');
+
+(async () => {
+ const t = await boot();
+ const alex = t.network.newUser('alex');
+ const bob = t.network.newUser('bob');
+ const profiles = loadProfiles();
+ const eff = async (robotId) => {
+ const row = await t.domain.robots.get(robotId);
+ return t.domain.control.effectiveLimits(row, profiles.get(row.profile_id));
+ };
+ const patch = (robotId, limits, user = alex) => t.call('PATCH', `/api/v1/robots/${robotId}`, { user, body: { limits } });
+ const commission = (robotId, body = { confirm: true }, user = alex) => t.call('POST', `/api/v1/robots/${robotId}/commission`, { user, body });
+ const uncommission = (robotId, user = alex) => t.call('POST', `/api/v1/robots/${robotId}/uncommission`, { user, body: {} });
+
+ await check('LOOI carries a ceiling above its defaults; a profile without one keeps its limits as the ceiling', () => {
+ assert.deepStrictEqual(profiles.get('looi').ceiling, { max_speed: 1, max_turn: 1 });
+ const cozmo = profiles.get('cozmo');
+ assert.deepStrictEqual(cozmo.ceiling, { max_speed: cozmo.limits.max_speed, max_turn: cozmo.limits.max_turn }, 'no ceiling: the limits clamp');
+ assert.deepStrictEqual(profiles.get('sim.rover').ceiling, { max_speed: profiles.get('sim.rover').limits.max_speed, max_turn: profiles.get('sim.rover').limits.max_turn });
+ });
+
+ await check('without commissioning, values above the profile default clamp to the default exactly as today', async () => {
+ const { robot } = await t.robot(alex, { profile_id: 'looi', limits: { max_speed: 0.9, max_turn: 0.9 } });
+ assert.deepStrictEqual(await eff(robot.id), { maxSpeed: 0.5, maxTurn: 0.6, maxCommandMs: 300, cooldownMs: 0 });
+ });
+
+ await check('commissioning needs the owner, a person and an explicit confirm', async () => {
+ const { robot } = await t.robot(alex, { profile_id: 'looi' });
+ const noConfirm = await commission(robot.id, {});
+ assert.strictEqual(noConfirm.status, 422);
+ assert.strictEqual(noConfirm.json.code, 'bot.confirm_required');
+ await t.call('POST', `/api/v1/robots/${robot.id}/operators`, { user: alex, body: { subject: bob.subject, role: 'operator' } });
+ const byOperator = await commission(robot.id, { confirm: true }, bob);
+ assert.strictEqual(byOperator.status, 403, byOperator.text);
+ // Even a service acting for the owner (X-OV-Subject) may not: only the owner's own person token.
+ const byService = await t.call('POST', `/api/v1/robots/${robot.id}/commission`, {
+ cap: ['bot.robot.manage'], headers: { 'x-ov-subject': alex.subject }, body: { confirm: true },
+ });
+ assert.strictEqual(byService.status, 403, 'an app token with manage still never commissions');
+ assert.strictEqual((await t.domain.robots.get(robot.id)).commissioned_at, null);
+ });
+
+ await check('an app token may lower a limit but never raise one, even on a commissioned robot', async () => {
+ const { robot } = await t.robot(alex, { profile_id: 'looi' });
+ await commission(robot.id);
+ const asOwner = { cap: ['bot.robot.manage'], headers: { 'x-ov-subject': alex.subject } };
+ const raise = await t.call('PATCH', `/api/v1/robots/${robot.id}`, { ...asOwner, body: { limits: { max_speed: 0.9 } } });
+ assert.strictEqual(raise.status, 403, raise.text);
+ assert.strictEqual((await eff(robot.id)).maxSpeed, 0.5);
+ const lower = await t.call('PATCH', `/api/v1/robots/${robot.id}`, { ...asOwner, body: { limits: { max_speed: 0.3 } } });
+ assert.strictEqual(lower.status, 200, lower.text);
+ assert.strictEqual((await eff(robot.id)).maxSpeed, 0.3);
+ });
+
+ await check('the owner commissions: who and when are recorded, and higher values apply up to the ceiling', async () => {
+ const { robot } = await t.robot(alex, { profile_id: 'looi' });
+ const r = await commission(robot.id);
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.json.robot.commissioned.by, alex.subject);
+ assert.ok(r.json.robot.commissioned.at);
+ assert.strictEqual((await patch(robot.id, { max_speed: 0.8, max_turn: 0.9 })).status, 200);
+ assert.deepStrictEqual(await eff(robot.id), { maxSpeed: 0.8, maxTurn: 0.9, maxCommandMs: 300, cooldownMs: 0 });
+ const audit = await t.domain.audit.list(robot.id, { limit: 20 });
+ const row = audit.find((a) => a.kind === 'robot.commission');
+ assert.ok(row && row.operator_subject === alex.subject && row.result === 'ack');
+ });
+
+ await check('never above the ceiling', async () => {
+ const { robot } = await t.robot(alex, { profile_id: 'looi' });
+ await commission(robot.id);
+ await patch(robot.id, { max_speed: 5, max_turn: 5 });
+ assert.deepStrictEqual(await eff(robot.id), { maxSpeed: 1, maxTurn: 1, maxCommandMs: 300, cooldownMs: 0 });
+ });
+
+ await check('max_command_ms is never raised, even when commissioned', async () => {
+ const { robot } = await t.robot(alex, { profile_id: 'looi' });
+ await commission(robot.id);
+ await patch(robot.id, { max_command_ms: 5000 });
+ assert.deepStrictEqual(await eff(robot.id), { maxSpeed: 0.5, maxTurn: 0.6, maxCommandMs: 300, cooldownMs: 0 });
+ });
+
+ await check('uncommissioning drops back to the defaults at once, and the audit says so', async () => {
+ const { robot } = await t.robot(alex, { profile_id: 'looi' });
+ await commission(robot.id);
+ await patch(robot.id, { max_speed: 0.9, max_turn: 0.9 });
+ assert.strictEqual((await eff(robot.id)).maxSpeed, 0.9);
+ const r = await uncommission(robot.id);
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.json.robot.commissioned, null);
+ assert.deepStrictEqual(await eff(robot.id), { maxSpeed: 0.5, maxTurn: 0.6, maxCommandMs: 300, cooldownMs: 0 });
+ const audit = await t.domain.audit.list(robot.id, { limit: 20 });
+ assert.ok(audit.some((a) => a.kind === 'robot.uncommission' && a.result === 'ack'));
+ });
+
+ await check('the device receives the effective values: defaults, higher after commissioning, defaults again', async () => {
+ const { robot, pairing } = await t.robot(alex, { profile_id: 'looi', limits: { max_speed: 0.9 } });
+ const p = await t.call('POST', '/api/v1/pair', { token: null, body: { robot: robot.id, code: pairing.code } });
+ const dev = await t.ws('/device', { headers: { Authorization: `Bearer ${p.json.credential}` } });
+ await dev.waitFor((m) => m.type === 'hello');
+ const first = await dev.waitFor((m) => m.type === 'config');
+ assert.deepStrictEqual(first.limits, { max_speed: 0.5, max_turn: 0.6, max_command_ms: 300, heartbeat_ms: 1000 }, 'clamped before commissioning');
+ await commission(robot.id);
+ await dev.waitFor((m) => m.type === 'config' && m.seq > first.seq);
+ const high = await patch(robot.id, { max_speed: 0.9, max_turn: 0.9 });
+ assert.strictEqual(high.status, 200);
+ const second = await dev.waitFor((m) => m.type === 'config' && m.seq > first.seq && m.limits.max_turn === 0.9);
+ assert.deepStrictEqual(second.limits, { max_speed: 0.9, max_turn: 0.9, max_command_ms: 300, heartbeat_ms: 1000 });
+ await uncommission(robot.id);
+ const third = await dev.waitFor((m) => m.type === 'config' && m.seq > second.seq && m.limits.max_speed === 0.5);
+ assert.deepStrictEqual(third.limits, { max_speed: 0.5, max_turn: 0.6, max_command_ms: 300, heartbeat_ms: 1000 }, 'at once, on the device');
+ dev.close();
+ });
+
+ await check('the owner\'s page: commissioning needs the tick; the limits form saves within the ceiling; others are refused', async () => {
+ const cookie = (user) => ({ Cookie: `ov_token=${t.network.signUser(user)}` });
+ const post = (p, user, form) => fetch(t.base + p, { method: 'POST', redirect: 'manual', body: new URLSearchParams(form).toString(),
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...(user ? cookie(user) : {}) } });
+ const { robot } = await t.robot(alex, { profile_id: 'looi' });
+ const page = await (await fetch(`${t.base}/panel/${robot.id}`, { headers: cookie(alex) })).text();
+ assert.match(page, /<input type="checkbox" name="confirm" value="on" required>/, 'the form asks the owner to say what they checked');
+ assert.strictEqual((await post(`/robots/${robot.id}/commission`, alex, {})).status, 422, 'no tick, no commissioning');
+ assert.strictEqual((await t.domain.robots.get(robot.id)).commissioned_at, null);
+ assert.strictEqual((await post(`/robots/${robot.id}/commission`, bob, { confirm: 'on' })).status, 403, 'not the owner');
+ assert.strictEqual((await post(`/robots/${robot.id}/commission`, alex, { confirm: 'on' })).status, 303);
+ assert.ok((await t.domain.robots.get(robot.id)).commissioned_at, 'the tick and the owner commission it');
+ assert.match(await (await fetch(`${t.base}/panel/${robot.id}`, { headers: cookie(alex) })).text(), /name="max_speed" min="0.05" max="1"/, 'then the page sets its own limits, up to the ceiling');
+ assert.strictEqual((await post(`/robots/${robot.id}/limits`, bob, { max_speed: '1', max_turn: '1' })).status, 403, 'others cannot set them');
+ assert.strictEqual((await post(`/robots/${robot.id}/limits`, alex, { max_speed: '2', max_turn: '1' })).status, 422, 'above 1 is refused');
+ assert.strictEqual((await post(`/robots/${robot.id}/limits`, alex, { max_speed: '0.9', max_turn: '0.8' })).status, 303);
+ assert.deepStrictEqual(await eff(robot.id), { maxSpeed: 0.9, maxTurn: 0.8, maxCommandMs: 300, cooldownMs: (await eff(robot.id)).cooldownMs }, 'saved and in force');
+ assert.strictEqual((await post(`/robots/${robot.id}/uncommission`, alex, {})).status, 303);
+ const back = await eff(robot.id);
+ assert.deepStrictEqual([back.maxSpeed, back.maxTurn], [0.5, 0.6], 'uncommissioned: the defaults at once, whatever is stored');
+ });
+
+ await t.close();
+ done();
+})();
@@ -1,8 +1,8 @@
@@ -1,8 +1,8 @@
'use strict';
'use strict';
// /llms.txt and /llms-full.txt (llmstxt.org): the plain-text map of Bot's public pages, built with
// /llms.txt and /llms-full.txt (llmstxt.org): the plain-text map of Bot's public pages, built with
// openvibe-shared/seo. The same pages as the sitemap minus /install (a 302 to the installer script, not a
// openvibe-shared/seo. The same pages as the sitemap minus /install (a 302 to the installer script, not a
-// page); no signed-in or per-robot page, and nothing under the API or /internal — except the two routes a
-// page names on purpose so an agent can find an account id and a robot's real limits (GET /me, /robots).
+// page); no signed-in or per-robot page, and nothing under the API or /internal — except the routes a page
+// names on purpose so an agent finds an account id, a robot's real limits and commissioning (GET /me, /robots).
const assert = require('assert');
const assert = require('assert');
const { boot, check, done } = require('./helpers/app');
const { boot, check, done } = require('./helpers/app');