openvibers/openvibe.bot Commits

Commissioned per-robot limits, up to a profile's hardware ceiling (#306)

OP OpenVibers committed 7 days ago · 52a3dc9bd0 · 11 files, +374 −18

* Commissioned per-robot limits, up to a profile's hardware ceiling

A profile may carry ceiling { max_speed, max_turn } (default: its limits). The
owner, as a person, may raise a robot's max_speed/max_turn above the profile
default and up to the ceiling only after POST /api/v1/robots/:id/commission
{ confirm: true }, which records who and when and audits robot.commission.
Without commissioning values clamp to the default exactly as before;
uncommissioning returns to them at once. max_command_ms is never raised.
Operators, apps and agents can never commission or raise a limit.
The gate and the device config still share effectiveLimits. LOOI gets a 1/1
ceiling. Panel card, protocol docs and llms.txt name the action.

* protocol: an app token may not raise a robot's limits

* Commissioning on the owner's page: a tick that says what was checked, and the limits form

Opus's review of the commissioned limits:
- The page's Commission button posted a hidden confirm, so one click raised the ceiling. The owner now ticks "I have
  checked this robot can move faster safely: there is clear space around it, and I can reach its e-stop"; without
  it the route answers 422 and nothing changes.
- A commissioned robot's page only pointed at PATCH /api/v1/robots/:id. It now sets its own top speed and top turn
  (number fields up to the profile's ceiling), through the same domain update and raise guard, and the device gets
  the new config at once.
- The commissioned time reads as a date and time, not an ISO string.
Tested: commission.test.js gains the page's checks (no tick → 422, not the owner → 403, above 1 → 422, saved
values in force, uncommissioned → the defaults whatever is stored); panel, api, llms and profiles pass.

Changes

ddiff --git a/docs/protocol.md b/docs/protocol.md
iindex 6e88d12..97553de 100644
---- a/docs/protocol.md
++++ b/docs/protocol.md
@@@ -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, so
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
`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
(`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
results, never a credential, a pairing code, a publish key or a WHIP URL.
ddiff --git a/migrations/0031_robot_commission.sql b/migrations/0031_robot_commission.sql
nnew file mode 100644
iindex 0000000..b3df2bd
---- /dev/null
++++ b/migrations/0031_robot_commission.sql
@@@ -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
ddiff --git a/server/api/v1.js b/server/api/v1.js
iindex d49a1bf..da92cbc 100644
---- a/server/api/v1.js
++++ b/server/api/v1.js
@@@ -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 once
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) => {
await owner(req, req.params.id);
await domain.robots.remove(req.params.id);
ddiff --git a/server/domain/index.js b/server/domain/index.js
iindex 6cce3f8..5422055 100644
---- a/server/domain/index.js
++++ b/server/domain/index.js
@@@ -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 },
ddiff --git a/server/profiles/index.js b/server/profiles/index.js
iindex c484465..7c49a64 100644
---- a/server/profiles/index.js
++++ b/server/profiles/index.js
@@@ -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, '.');
ddiff --git a/server/profiles/looi.json b/server/profiles/looi.json
iindex ad89878..470069f 100644
---- a/server/profiles/looi.json
++++ b/server/profiles/looi.json
@@@ -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 }
}
ddiff --git a/server/web/docs.js b/server/web/docs.js
iindex ec9b87f..6ba97af 100644
---- a/server/web/docs.js
++++ b/server/web/docs.js
@@@ -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 });
}
ddiff --git a/server/web/render.js b/server/web/render.js
iindex f53a13e..0e0a3ea 100644
---- a/server/web/render.js
++++ b/server/web/render.js
@@@ -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',
ddiff --git a/server/web/routes.js b/server/web/routes.js
iindex 3dde3fb..e63c0bf 100644
---- a/server/web/routes.js
++++ b/server/web/routes.js
@@@ -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, limi
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) => {
const me = requireUser(req);
if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');
ddiff --git a/test/commission.test.js b/test/commission.test.js
nnew file mode 100644
iindex 0000000..2f69152
---- /dev/null
++++ b/test/commission.test.js
@@@ -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();
++})();
ddiff --git a/test/llms.test.js b/test/llms.test.js
iindex abef3d7..2b1f610 100644
---- a/test/llms.test.js
++++ b/test/llms.test.js
@@@ -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');