openvibers/openvibe.bot Commits
A robot's owner and the limits it really enforces, and GET /me (#305)
Changes
@@@ -528,7 +528,7 @@ shown; a service acts for `X-OV-Subject` / the body's `owner`. A person acts onlanyone else (`?owner=`, the body's `owner`, `X-OV-Subject`) is `403 bot.forbidden`; `?owner=` is refused,never ignored, on both `GET /robots` and `POST /robots` (a service's `?owner=` on `POST /robots` must namethe subject it acts for). Without a token, every route but `GET /profiles`, `GET /profiles/:id`, `POST /pair`--and `POST /devices/bind` answers `401 bot.sign_in`: `GET|POST /robots`, `GET|PATCH|DELETE /robots/:id`,++and `POST /devices/bind` answers `401 bot.sign_in`: `GET /me`, `GET|POST /robots`, `GET|PATCH|DELETE /robots/:id`,`POST /robots/:id/pairing-code`, `GET|POST /robots/:id/operators`, `DELETE /robots/:id/operators/:subject`,`GET /robots/:id/devices`, `POST /devices/:id/rotate|revoke`, `POST /robots/:id/estop`,`POST /robots/:id/estop/clear`, `POST /robots/:id/commands`, `GET /robots/:id/audit` and `GET|POST /robots/:id/streaming`. A node token gets `403 bot.forbidden` on the same@@@ -539,9 +539,10 @@ node token, and a service token is judged on `bot.job.dispatch` alone.| Method & path | Auth (capability) | Answer ||---|---|---|| `GET /profiles`, `GET /profiles/:id` | public | `{ profiles[] }` / `{ profile }` |--| `GET /robots` | user (own robots), or `bot.robot.read` + `?owner=` | `{ robots[] }` |++| `GET /me` | a person, or an app token (its own subject) | `{ subject, username, display_name, kind }`: the `usr_…` id an owner names |++| `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 }` |++| `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 || `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` |@@@ -574,6 +575,31 @@ the panel's owner-only forms `POST /robots/:id/operators` (body `username`, `rol(`server/web/routes.js`; adding a robot and minting a code count against the `bot.robot.manage` per-person limit,30 a minute and 300 an hour, as on `/api/v1`; past it `429 rate_limited`).++### The account ID, and a robot's real limits++++`GET /me` answers the caller's `usr_…` subject — the id an owner names when adding an operator, and what++a service acts for. A person's token answers for that person; an app token answers for its own `sub`.++The same id is on <https://openvibe.network/my>, and Network's `GET https://openvibe.network/api/auth/me`++carries it as `user.subject_id`.++++A robot's `limits` is only what the owner set (often `{}`), so `GET /robots` and `GET /robots/:id` add++the values the gate and the device really use:++++```json++{ "robot": {++ "id": "rob_…", "owner_subject": "usr_…",++ "limits": { "max_speed": 0.4 },++ "effective_limits": { "max_speed": 0.4, "max_turn": 0.6, "max_command_ms": 300, "heartbeat_ms": 1000 },++ "limits_source": {++ "profile": { "max_speed": 0.5, "max_turn": 0.6, "max_command_ms": 300, "heartbeat_ms": 1000 },++ "robot": { "max_speed": 0.4 }++ }++} }++```++++`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.++---## 4. Audit and events@@@ -53,7 +53,7 @@navigator.clipboard.writeText(src.textContent).then(() => {b.dataset.copied = 'true';if (label) label.textContent = 'Copied';-- setTimeout(() => { delete b.dataset.copied; if (label) label.textContent = 'Copy the command'; }, 2000);++ setTimeout(() => { delete b.dataset.copied; if (label) label.textContent = b.dataset.copyLabel || 'Copy the command'; }, 2000);}, () => {});}});@@@ -12,6 +12,7 @@* | POST /profiles/validate | — (public) | anyone (the profile validator) |* | GET /kits, /kits/:id | — (public) | anyone |* | GET /resources, /resources/:ovrn | bot.resource.read | — (services only, loopback) |++ * | GET /me | — (a person) | anyone signed in (an app token: itself) |* | GET /robots | bot.robot.read | own robots (?owner= only themself) |* | POST /robots | bot.robot.manage | the owner (new robot + pairing code)|* | GET /robots/:id | bot.robot.read | a member (owner/operator/viewer) |@@@ -217,13 +218,46 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, submres.json({ kit });}));++ // ── The caller's account ─────────────────────────────────────────────────────────────────────++ // Who this token is: the one place a person (or their agent) asks for the usr_ id an owner names++ // elsewhere on the network. A service token answers for its own subject, never for a person.++ r.get('/me', wrap(async (req, res) => {++ fence(req);++ if (req.principal.kind === 'service') {++ return res.json({ subject: req.principal.sub, username: null, display_name: null, kind: 'service' });++ }++ res.json({++ subject: req.principal.subject, username: req.principal.username || null,++ display_name: req.principal.name || null, kind: 'user',++ });++ }));++// ── Robots ────────────────────────────────────────────────────────────────────────────────────++ /**++ * A robot as /api/v1 answers it: its owner, the limits actually enforced (never a bare `{}` that reads++ * as "no limits"), and where each came from. The gate and the device's config frame use the same++ * computation, so this can never disagree with what the robot does.++ */++ async function presentRobot(robot) {++ const row = await getProfile(domain.db, robot.profile_id, robot.profile_version);++ const profile = row ? row.profile : null;++ const overrides = json(robot.limits, {});++ const fromRobot = {};++ for (const k of ['max_speed', 'max_turn', 'max_command_ms']) if (overrides[k] != null) fromRobot[k] = overrides[k];++ return {++ ...domain.present.robot(robot),++ owner_subject: robot.owner_subject,++ effective_limits: domain.control.deviceLimits(robot, profile),++ limits_source: { profile: (profile && profile.limits) || {}, robot: fromRobot },++ };++ }++ const presentRobots = (rows) => Promise.all(rows.map(presentRobot));r.get('/robots', wrap(async (req, res) => {requireRead(req);const target = req.principal.kind === 'user' ? self(req, req.query.owner, req.headers['x-ov-subject']): (req.query.owner ? userSubject(req.query.owner, 'owner') : null);if (!target) fail(422, 'bot.invalid_input', 'owner is required');-- res.json({ robots: (await domain.robots.list(target)).map(domain.present.robot) });++ res.json({ robots: await presentRobots(await domain.robots.list(target)) });}));r.post('/robots', manage, wrap(async (req, res) => {const ownerSubject = requireManage(req, req.body?.owner);@@@ -233,17 +267,17 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, submowner: ownerSubject, name: req.body?.name, profile_id: req.body?.profile_id,access_policy: req.body?.access_policy, limits: req.body?.limits || {},});-- res.status(201).json({ robot: domain.present.robot(robot), pairing });++ res.status(201).json({ robot: await presentRobot(robot), pairing });}));r.get('/robots/:id', wrap(async (req, res) => {const { robot, role } = await member(req, req.params.id);-- res.json({ robot: domain.present.robot(robot), role });++ res.json({ robot: await presentRobot(robot), role });}));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 });if (req.body?.limits !== undefined) await hub.refreshConfig(req.params.id); // the device enforces the new limits and allowlist at once-- res.json({ robot: domain.present.robot(updated) });++ res.json({ robot: await presentRobot(updated) });}));r.delete('/robots/:id', manage, wrap(async (req, res) => {await owner(req, req.params.id);@@@ -299,6 +299,10 @@ app.post('/robot-events', express.text({ type: 'application/json' }), (req, res)res.status(204).end();});`;++const ACCOUNT_CALL = `curl https://openvibe.bot/api/v1/me \\++ -H "Authorization: Bearer $OPENVIBE_TOKEN"++# → {"subject": "usr_…", "username": "alex", "display_name": "Alex"}`;++const STEPS_CALL = `curl -X POST https://openvibe.bot/api/v1/robots/rob_…/steps \\-H "Authorization: Bearer $OPENVIBE_TOKEN" -H "Content-Type: application/json" \\-d '{"steps": "forward 1\\nleft 0.5\\nheadlights #00ff00\\nstop"}'@@@ -341,6 +345,11 @@ ${code(WEBHOOK_VERIFY)}<p>Your own token runs steps directly, and answers when they end:</p>${code(STEPS_CALL)}<p class="muted"><code>GET /api/v1/robots/:id/steps</code> answers the robot's state and the step words it takes. A call takes at most 20 steps and 30 seconds; the agent's calls are refused with <code>403 bot.agent_not_allowed</code> while the switch is off.</p></section>++++<section class="card" aria-labelledby="account-h"><h2 id="account-h">Find your account ID</h2>++<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>`;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 });}@@@ -488,7 +488,30 @@ function turnStrip(robot, role) {return `<div class="turn" data-turn hidden><span class="turn-state" data-turn-state></span><span class="turn-meta" data-turn-meta></span>${leave}${give}</div>`;}--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 }) {++/**++ * The owner's limits card: the values the gate and the device actually enforce, each with where it came++ * from (the profile's default, or set for this robot), and the owner's account id. `robot.limits` alone++ * can be `{}`, which reads as "no limits"; these are the numbers the robot really uses.++ */++function limitsCard(robot, info, profile = null) {++ if (!info) return '';++ const { effective, profileName, robotLimits = {}, subject } = info;++ const row = (label, key, shown) => {++ const source = robotLimits[key] != null ? 'set for this robot' : `from the ${profileName} profile`;++ return `<li><b>${esc(label)}</b> <span>${esc(shown)}</span> <small>${esc(source)}</small></li>`;++ };++ return `<section class="setting-card" aria-labelledby="limits-h"><h2 id="limits-h">Limits</h2>++<p class="setting-note">What this robot enforces on every command, from anyone including you. A profile value applies until you set one for this robot.</p>++<ul class="people-list">++${row('Top speed', 'max_speed', effective.maxSpeed)}++${row('Turning', 'max_turn', effective.maxTurn)}++${row('Longest command', 'max_command_ms', `${effective.maxCommandMs} ms`)}++</ul>++<p class="setting-note">Your account ID <code data-copy-source>${esc(subject)}</code> <button type="button" class="copy" data-copy data-copy-label="Copy"><span data-copy-label>Copy</span></button></p>++</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);const widgets = (profile.widgets || []).map((w, i) => ({ w, i }))@@@ -515,7 +538,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">${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)}${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',@@@ -37,7 +37,7 @@ const seo = require('openvibe-shared/seo');const appIcon = require('openvibe-shared/app-icon');const { getProfile, listProfiles, loadProfiles } = require('../profiles');const { frameAncestors } = require('../config');--const { BotError } = require('../util');++const { BotError, json } = require('../util');const kits = require('../kits');const { renderPanel, renderEmbedRefused, renderDrivePage, renderPairingPage, renderPhonePage, renderMachinesPage, renderMachineDesksPage, renderMachineScreenPage, renderSharePage, renderShareGone, renderLinkPage, renderSignInPage, renderClaimPage, renderClaimApproved, renderFirstRunPage } = require('./render');const { createMachineScreen } = require('../machine-screen');@@@ -113,7 +113,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). 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. 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',@@@ -1082,7 +1082,13 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi}async function sendPanel(res, robot, profile, role, mode = 'page', signedIn = true, profileForm = {}) {await attachInProcess(robot, profile);-- const { maxCommandMs } = domain.control.effectiveLimits(robot, profile);++ const effective = domain.control.effectiveLimits(robot, profile);++ const { maxCommandMs } = effective;++ // The owner's card: what the gate and the device actually enforce, and whether each came from the++ // profile or was set for this robot (never leave an owner reading a bare `{}` as "no limits").++ const limitsInfo = role === 'owner' && mode === 'page'++ ? { effective, profileName: profile.name || robot.profile_id, robotLimits: json(robot.limits, {}), subject: robot.owner_subject }++ : null;// Two OpenRestream reads, run together. Only the owner's own page shows the streaming switches, greyed out// with a reason when OpenRestream does not answer; the live session is for the camera tile of every page,// where it is left a placeholder instead. A watcher is the anonymous embed and only exists when the@@@ -1108,7 +1114,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limiconst liveOrigin = (config.embed && config.embed.origins && config.embed.origins[0]) || null;res.type('html').send(renderPanel({robot: presentForPage(robot), profile, role, mode, signedIn, streaming, people, signaling_url,-- profileForm, automation,++ profileForm, automation, limitsInfo,// The page that frames the embed may hand it a Drive ticket (public/panel.js); the owner's page offers to put// the panel under their Live stream.parentOrigins: mode === 'embed' ? ((config.embed && config.embed.origins) || []) : [],@@@ -1,7 +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.++// 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).const assert = require('assert');const { boot, check, done } = require('./helpers/app');@@@ -62,7 +63,9 @@ const TITLES = ['OpenVibe.Bot — drive your robot from any browser', 'Build for}TITLES.forEach((title, i) => assert.ok(body.includes(`### ${title}`), `llms-full.txt has ${title}`));PATHS.forEach((p) => assert.ok(body.includes(`URL: ${BASE}${p}`), `llms-full.txt has the absolute URL for ${p}`));-- for (const bad of ['/api', '/internal', '/panel/']) assert.ok(!body.includes(bad), `llms-full.txt does not name ${bad}`);++ for (const bad of ['/internal', '/panel/']) assert.ok(!body.includes(bad), `llms-full.txt does not name ${bad}`);++ // The account id and a robot's real limits are named on purpose; no other API path is.++ assert.ok(!/\/api\/v1\/(?!me\b|robots\b)/.test(body), 'llms-full.txt names no API path but /me and /robots');});await t.close();nnew file mode 100644@@@ -0,0 +1,69 @@++'use strict';++// PR1: a robot's owner and the limits it really enforces, and GET /me for the account ID.++const assert = require('assert');++const { boot, check, done } = require('./helpers/app');++++(async () => {++ const t = await boot();++ const alex = t.network.newUser('alex');++ const invited = t.network.newUser('invited');++ const stranger = t.network.newUser('stranger');++++ await check('GET /me answers the token\'s own account', async () => {++ const me = await t.call('GET', '/api/v1/me', { user: alex });++ assert.strictEqual(me.status, 200, me.text);++ assert.strictEqual(me.json.subject, alex.subject);++ assert.strictEqual(me.json.username, 'alex');++ assert.strictEqual(me.json.display_name, 'Alex');++ assert.strictEqual(me.json.kind, 'user');++ assert.strictEqual((await t.call('GET', '/api/v1/me', { token: null })).status, 401);++ });++++ await check('an app token answers for its own subject, never a person\'s', async () => {++ const r = await t.call('GET', '/api/v1/me', { sub: 'svc:actor' });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.json.subject, 'svc:actor');++ assert.strictEqual(r.json.kind, 'service');++ assert.strictEqual(r.json.username, null);++ });++++ await check('GET /robots/:id carries owner_subject, effective_limits and limits_source', async () => {++ const { robot } = await t.robot(alex, { profile_id: 'looi', limits: { max_speed: 0.4 } });++ const r = await t.call('GET', `/api/v1/robots/${robot.id}`, { user: alex });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.json.robot.owner_subject, alex.subject);++ assert.deepStrictEqual(r.json.robot.effective_limits, { max_speed: 0.4, max_turn: 0.6, max_command_ms: 300, heartbeat_ms: 1000 });++ assert.deepStrictEqual(r.json.robot.limits_source.profile, { max_speed: 0.5, max_turn: 0.6, max_command_ms: 300, heartbeat_ms: 1000 });++ assert.deepStrictEqual(r.json.robot.limits_source.robot, { max_speed: 0.4 }, 'only the owner\'s overrides');++ });++++ await check('a robot with no limits{} says the profile\'s effective values, not {}', async () => {++ const { robot } = await t.robot(alex, { profile_id: 'looi' });++ const r = await t.call('GET', `/api/v1/robots/${robot.id}`, { user: alex });++ assert.deepStrictEqual(r.json.robot.limits, {});++ assert.deepStrictEqual(r.json.robot.effective_limits, { max_speed: 0.5, max_turn: 0.6, max_command_ms: 300, heartbeat_ms: 1000 });++ assert.deepStrictEqual(r.json.robot.limits_source.robot, {});++ });++++ await check('the list carries the same fields, for a member and for a service', async () => {++ const { robot } = await t.robot(alex, { profile_id: 'looi', limits: { max_turn: 0.2 } });++ await t.call('POST', `/api/v1/robots/${robot.id}/operators`, { user: alex, body: { subject: invited.subject, role: 'operator' } });++ const seen = await t.call('GET', `/api/v1/robots/${robot.id}`, { user: invited });++ assert.strictEqual(seen.status, 200);++ assert.strictEqual(seen.json.role, 'operator');++ assert.strictEqual(seen.json.robot.owner_subject, alex.subject);++ assert.strictEqual(seen.json.robot.effective_limits.max_turn, 0.2);++ const list = await t.call('GET', '/api/v1/robots', { user: alex });++ const one = list.json.robots.find((x) => x.id === robot.id);++ assert.ok(one);++ assert.strictEqual(one.owner_subject, alex.subject);++ assert.strictEqual(one.effective_limits.max_turn, 0.2);++ const svc = await t.call('GET', `/api/v1/robots?owner=${alex.subject}`, { cap: ['bot.robot.read'] });++ assert.strictEqual(svc.status, 200, svc.text);++ assert.strictEqual(svc.json.robots.find((x) => x.id === robot.id).effective_limits.max_speed, 0.5);++ assert.strictEqual((await t.call('GET', '/api/v1/robots', { user: stranger })).json.robots.length, 0);++ });++++ await t.close();++ done();++})();
@@ -528,7 +528,7 @@ shown; a service acts for `X-OV-Subject` / the body's `owner`. A person acts onl
@@ -528,7 +528,7 @@ shown; a service acts for `X-OV-Subject` / the body's `owner`. A person acts onl
anyone else (`?owner=`, the body's `owner`, `X-OV-Subject`) is `403 bot.forbidden`; `?owner=` is refused,
anyone else (`?owner=`, the body's `owner`, `X-OV-Subject`) is `403 bot.forbidden`; `?owner=` is refused,
never ignored, on both `GET /robots` and `POST /robots` (a service's `?owner=` on `POST /robots` must name
never ignored, on both `GET /robots` and `POST /robots` (a service's `?owner=` on `POST /robots` must name
the subject it acts for). Without a token, every route but `GET /profiles`, `GET /profiles/:id`, `POST /pair`
the subject it acts for). Without a token, every route but `GET /profiles`, `GET /profiles/:id`, `POST /pair`
-and `POST /devices/bind` answers `401 bot.sign_in`: `GET|POST /robots`, `GET|PATCH|DELETE /robots/:id`,
+and `POST /devices/bind` answers `401 bot.sign_in`: `GET /me`, `GET|POST /robots`, `GET|PATCH|DELETE /robots/:id`,
`POST /robots/:id/pairing-code`, `GET|POST /robots/:id/operators`, `DELETE /robots/:id/operators/:subject`,
`POST /robots/:id/pairing-code`, `GET|POST /robots/:id/operators`, `DELETE /robots/:id/operators/:subject`,
`GET /robots/:id/devices`, `POST /devices/:id/rotate|revoke`, `POST /robots/:id/estop`,
`GET /robots/:id/devices`, `POST /devices/:id/rotate|revoke`, `POST /robots/:id/estop`,
`POST /robots/:id/estop/clear`, `POST /robots/:id/commands`, `GET /robots/:id/audit` and `GET|POST /robots/:id/streaming`. A node token gets `403 bot.forbidden` on the same
`POST /robots/:id/estop/clear`, `POST /robots/:id/commands`, `GET /robots/:id/audit` and `GET|POST /robots/:id/streaming`. A node token gets `403 bot.forbidden` on the same
@@ -539,9 +539,10 @@ node token, and a service token is judged on `bot.job.dispatch` alone.
@@ -539,9 +539,10 @@ node token, and a service token is judged on `bot.job.dispatch` alone.
| Method & path | Auth (capability) | Answer |
| Method & path | Auth (capability) | Answer |
|---|---|---|
|---|---|---|
| `GET /profiles`, `GET /profiles/:id` | public | `{ profiles[] }` / `{ profile }` |
| `GET /profiles`, `GET /profiles/:id` | public | `{ profiles[] }` / `{ profile }` |
-| `GET /robots` | user (own robots), or `bot.robot.read` + `?owner=` | `{ robots[] }` |
+| `GET /me` | a person, or an app token (its own subject) | `{ subject, username, display_name, kind }`: the `usr_…` id an owner names |
+| `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 }` |
+| `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 |
| `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` |
@@ -574,6 +575,31 @@ the panel's owner-only forms `POST /robots/:id/operators` (body `username`, `rol
@@ -574,6 +575,31 @@ the panel's owner-only forms `POST /robots/:id/operators` (body `username`, `rol
(`server/web/routes.js`; adding a robot and minting a code count against the `bot.robot.manage` per-person limit,
(`server/web/routes.js`; adding a robot and minting a code count against the `bot.robot.manage` per-person limit,
30 a minute and 300 an hour, as on `/api/v1`; past it `429 rate_limited`).
30 a minute and 300 an hour, as on `/api/v1`; past it `429 rate_limited`).
+### The account ID, and a robot's real limits
+
+`GET /me` answers the caller's `usr_…` subject — the id an owner names when adding an operator, and what
+a service acts for. A person's token answers for that person; an app token answers for its own `sub`.
+The same id is on <https://openvibe.network/my>, and Network's `GET https://openvibe.network/api/auth/me`
+carries it as `user.subject_id`.
+
+A robot's `limits` is only what the owner set (often `{}`), so `GET /robots` and `GET /robots/:id` add
+the values the gate and the device really use:
+
+```json
+{ "robot": {
+ "id": "rob_…", "owner_subject": "usr_…",
+ "limits": { "max_speed": 0.4 },
+ "effective_limits": { "max_speed": 0.4, "max_turn": 0.6, "max_command_ms": 300, "heartbeat_ms": 1000 },
+ "limits_source": {
+ "profile": { "max_speed": 0.5, "max_turn": 0.6, "max_command_ms": 300, "heartbeat_ms": 1000 },
+ "robot": { "max_speed": 0.4 }
+ }
+} }
+```
+
+`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.
+
---
---
## 4. Audit and events
## 4. Audit and events
@@ -53,7 +53,7 @@
@@ -53,7 +53,7 @@
navigator.clipboard.writeText(src.textContent).then(() => {
navigator.clipboard.writeText(src.textContent).then(() => {
b.dataset.copied = 'true';
b.dataset.copied = 'true';
if (label) label.textContent = 'Copied';
if (label) label.textContent = 'Copied';
- setTimeout(() => { delete b.dataset.copied; if (label) label.textContent = 'Copy the command'; }, 2000);
+ setTimeout(() => { delete b.dataset.copied; if (label) label.textContent = b.dataset.copyLabel || 'Copy the command'; }, 2000);
}, () => {});
}, () => {});
}
}
});
});
@@ -12,6 +12,7 @@
@@ -12,6 +12,7 @@
* | POST /profiles/validate | — (public) | anyone (the profile validator) |
* | POST /profiles/validate | — (public) | anyone (the profile validator) |
* | GET /kits, /kits/:id | — (public) | anyone |
* | GET /kits, /kits/:id | — (public) | anyone |
* | GET /resources, /resources/:ovrn | bot.resource.read | — (services only, loopback) |
* | GET /resources, /resources/:ovrn | bot.resource.read | — (services only, loopback) |
+ * | GET /me | — (a person) | anyone signed in (an app token: itself) |
* | GET /robots | bot.robot.read | own robots (?owner= only themself) |
* | GET /robots | bot.robot.read | own robots (?owner= only themself) |
* | 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) |
@@ -217,13 +218,46 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
@@ -217,13 +218,46 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
res.json({ kit });
res.json({ kit });
}));
}));
+ // ── The caller's account ─────────────────────────────────────────────────────────────────────
+ // Who this token is: the one place a person (or their agent) asks for the usr_ id an owner names
+ // elsewhere on the network. A service token answers for its own subject, never for a person.
+ r.get('/me', wrap(async (req, res) => {
+ fence(req);
+ if (req.principal.kind === 'service') {
+ return res.json({ subject: req.principal.sub, username: null, display_name: null, kind: 'service' });
+ }
+ res.json({
+ subject: req.principal.subject, username: req.principal.username || null,
+ display_name: req.principal.name || null, kind: 'user',
+ });
+ }));
+
// ── Robots ────────────────────────────────────────────────────────────────────────────────────
// ── Robots ────────────────────────────────────────────────────────────────────────────────────
+ /**
+ * A robot as /api/v1 answers it: its owner, the limits actually enforced (never a bare `{}` that reads
+ * as "no limits"), and where each came from. The gate and the device's config frame use the same
+ * computation, so this can never disagree with what the robot does.
+ */
+ async function presentRobot(robot) {
+ const row = await getProfile(domain.db, robot.profile_id, robot.profile_version);
+ const profile = row ? row.profile : null;
+ const overrides = json(robot.limits, {});
+ const fromRobot = {};
+ for (const k of ['max_speed', 'max_turn', 'max_command_ms']) if (overrides[k] != null) fromRobot[k] = overrides[k];
+ return {
+ ...domain.present.robot(robot),
+ owner_subject: robot.owner_subject,
+ effective_limits: domain.control.deviceLimits(robot, profile),
+ limits_source: { profile: (profile && profile.limits) || {}, robot: fromRobot },
+ };
+ }
+ const presentRobots = (rows) => Promise.all(rows.map(presentRobot));
r.get('/robots', wrap(async (req, res) => {
r.get('/robots', wrap(async (req, res) => {
requireRead(req);
requireRead(req);
const target = req.principal.kind === 'user' ? self(req, req.query.owner, req.headers['x-ov-subject'])
const target = req.principal.kind === 'user' ? self(req, req.query.owner, req.headers['x-ov-subject'])
: (req.query.owner ? userSubject(req.query.owner, 'owner') : null);
: (req.query.owner ? userSubject(req.query.owner, 'owner') : null);
if (!target) fail(422, 'bot.invalid_input', 'owner is required');
if (!target) fail(422, 'bot.invalid_input', 'owner is required');
- res.json({ robots: (await domain.robots.list(target)).map(domain.present.robot) });
+ res.json({ robots: await presentRobots(await domain.robots.list(target)) });
}));
}));
r.post('/robots', manage, wrap(async (req, res) => {
r.post('/robots', manage, wrap(async (req, res) => {
const ownerSubject = requireManage(req, req.body?.owner);
const ownerSubject = requireManage(req, req.body?.owner);
@@ -233,17 +267,17 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
@@ -233,17 +267,17 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
owner: ownerSubject, name: req.body?.name, profile_id: req.body?.profile_id,
owner: ownerSubject, name: req.body?.name, profile_id: req.body?.profile_id,
access_policy: req.body?.access_policy, limits: req.body?.limits || {},
access_policy: req.body?.access_policy, limits: req.body?.limits || {},
});
});
- res.status(201).json({ robot: domain.present.robot(robot), pairing });
+ res.status(201).json({ robot: await presentRobot(robot), pairing });
}));
}));
r.get('/robots/:id', wrap(async (req, res) => {
r.get('/robots/:id', wrap(async (req, res) => {
const { robot, role } = await member(req, req.params.id);
const { robot, role } = await member(req, req.params.id);
- res.json({ robot: domain.present.robot(robot), role });
+ res.json({ robot: await presentRobot(robot), role });
}));
}));
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 });
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: domain.present.robot(updated) });
+ res.json({ robot: await presentRobot(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);
@@ -299,6 +299,10 @@ app.post('/robot-events', express.text({ type: 'application/json' }), (req, res)
@@ -299,6 +299,10 @@ app.post('/robot-events', express.text({ type: 'application/json' }), (req, res)
res.status(204).end();
res.status(204).end();
});`;
});`;
+const ACCOUNT_CALL = `curl https://openvibe.bot/api/v1/me \\
+ -H "Authorization: Bearer $OPENVIBE_TOKEN"
+# → {"subject": "usr_…", "username": "alex", "display_name": "Alex"}`;
+
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" \\
-d '{"steps": "forward 1\\nleft 0.5\\nheadlights #00ff00\\nstop"}'
-d '{"steps": "forward 1\\nleft 0.5\\nheadlights #00ff00\\nstop"}'
@@ -341,6 +345,11 @@ ${code(WEBHOOK_VERIFY)}
@@ -341,6 +345,11 @@ ${code(WEBHOOK_VERIFY)}
<p>Your own token runs steps directly, and answers when they end:</p>
<p>Your own token runs steps directly, and answers when they end:</p>
${code(STEPS_CALL)}
${code(STEPS_CALL)}
<p class="muted"><code>GET /api/v1/robots/:id/steps</code> answers the robot's state and the step words it takes. A call takes at most 20 steps and 30 seconds; the agent's calls are refused with <code>403 bot.agent_not_allowed</code> while the switch is off.</p></section>
<p class="muted"><code>GET /api/v1/robots/:id/steps</code> answers the robot's state and the step words it takes. A call takes at most 20 steps and 30 seconds; the agent's calls are refused with <code>403 bot.agent_not_allowed</code> while the switch is off.</p></section>
+
+<section class="card" aria-labelledby="account-h"><h2 id="account-h">Find your account ID</h2>
+<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>
`;
`;
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 });
}
}
@@ -488,7 +488,30 @@ function turnStrip(robot, role) {
@@ -488,7 +488,30 @@ function turnStrip(robot, role) {
return `<div class="turn" data-turn hidden><span class="turn-state" data-turn-state></span><span class="turn-meta" data-turn-meta></span>${leave}${give}</div>`;
return `<div class="turn" data-turn hidden><span class="turn-state" data-turn-state></span><span class="turn-meta" data-turn-meta></span>${leave}${give}</div>`;
}
}
-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 }) {
+/**
+ * The owner's limits card: the values the gate and the device actually enforce, each with where it came
+ * from (the profile's default, or set for this robot), and the owner's account id. `robot.limits` alone
+ * can be `{}`, which reads as "no limits"; these are the numbers the robot really uses.
+ */
+function limitsCard(robot, info, profile = null) {
+ if (!info) return '';
+ const { effective, profileName, robotLimits = {}, subject } = info;
+ const row = (label, key, shown) => {
+ const source = robotLimits[key] != null ? 'set for this robot' : `from the ${profileName} profile`;
+ return `<li><b>${esc(label)}</b> <span>${esc(shown)}</span> <small>${esc(source)}</small></li>`;
+ };
+ return `<section class="setting-card" aria-labelledby="limits-h"><h2 id="limits-h">Limits</h2>
+<p class="setting-note">What this robot enforces on every command, from anyone including you. A profile value applies until you set one for this robot.</p>
+<ul class="people-list">
+${row('Top speed', 'max_speed', effective.maxSpeed)}
+${row('Turning', 'max_turn', effective.maxTurn)}
+${row('Longest command', 'max_command_ms', `${effective.maxCommandMs} ms`)}
+</ul>
+<p class="setting-note">Your account ID <code data-copy-source>${esc(subject)}</code> <button type="button" class="copy" data-copy data-copy-label="Copy"><span data-copy-label>Copy</span></button></p>
+</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 embed = mode === 'embed';
const latched = !!(robot.estop && robot.estop.latched);
const latched = !!(robot.estop && robot.estop.latched);
const widgets = (profile.widgets || []).map((w, i) => ({ w, i }))
const widgets = (profile.widgets || []).map((w, i) => ({ w, i }))
@@ -515,7 +538,7 @@ ${embed && role === 'watcher' && !signedIn ? `<p class="embed-sign-in" data-driv
@@ -515,7 +538,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">${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)}${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',
@@ -37,7 +37,7 @@ const seo = require('openvibe-shared/seo');
@@ -37,7 +37,7 @@ const seo = require('openvibe-shared/seo');
const appIcon = require('openvibe-shared/app-icon');
const appIcon = require('openvibe-shared/app-icon');
const { getProfile, listProfiles, loadProfiles } = require('../profiles');
const { getProfile, listProfiles, loadProfiles } = require('../profiles');
const { frameAncestors } = require('../config');
const { frameAncestors } = require('../config');
-const { BotError } = require('../util');
+const { BotError, json } = require('../util');
const kits = require('../kits');
const kits = require('../kits');
const { renderPanel, renderEmbedRefused, renderDrivePage, renderPairingPage, renderPhonePage, renderMachinesPage, renderMachineDesksPage, renderMachineScreenPage, renderSharePage, renderShareGone, renderLinkPage, renderSignInPage, renderClaimPage, renderClaimApproved, renderFirstRunPage } = require('./render');
const { renderPanel, renderEmbedRefused, renderDrivePage, renderPairingPage, renderPhonePage, renderMachinesPage, renderMachineDesksPage, renderMachineScreenPage, renderSharePage, renderShareGone, renderLinkPage, renderSignInPage, renderClaimPage, renderClaimApproved, renderFirstRunPage } = require('./render');
const { createMachineScreen } = require('../machine-screen');
const { createMachineScreen } = require('../machine-screen');
@@ -113,7 +113,7 @@ const LLMS_PAGES = [
@@ -113,7 +113,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). 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. 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',
@@ -1082,7 +1082,13 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
@@ -1082,7 +1082,13 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
}
}
async function sendPanel(res, robot, profile, role, mode = 'page', signedIn = true, profileForm = {}) {
async function sendPanel(res, robot, profile, role, mode = 'page', signedIn = true, profileForm = {}) {
await attachInProcess(robot, profile);
await attachInProcess(robot, profile);
- const { maxCommandMs } = domain.control.effectiveLimits(robot, profile);
+ const effective = domain.control.effectiveLimits(robot, profile);
+ const { maxCommandMs } = effective;
+ // The owner's card: what the gate and the device actually enforce, and whether each came from the
+ // profile or was set for this robot (never leave an owner reading a bare `{}` as "no limits").
+ const limitsInfo = role === 'owner' && mode === 'page'
+ ? { effective, profileName: profile.name || robot.profile_id, robotLimits: json(robot.limits, {}), subject: robot.owner_subject }
+ : null;
// Two OpenRestream reads, run together. Only the owner's own page shows the streaming switches, greyed out
// Two OpenRestream reads, run together. Only the owner's own page shows the streaming switches, greyed out
// with a reason when OpenRestream does not answer; the live session is for the camera tile of every page,
// with a reason when OpenRestream does not answer; the live session is for the camera tile of every page,
// where it is left a placeholder instead. A watcher is the anonymous embed and only exists when the
// where it is left a placeholder instead. A watcher is the anonymous embed and only exists when the
@@ -1108,7 +1114,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
@@ -1108,7 +1114,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
const liveOrigin = (config.embed && config.embed.origins && config.embed.origins[0]) || null;
const liveOrigin = (config.embed && config.embed.origins && config.embed.origins[0]) || null;
res.type('html').send(renderPanel({
res.type('html').send(renderPanel({
robot: presentForPage(robot), profile, role, mode, signedIn, streaming, people, signaling_url,
robot: presentForPage(robot), profile, role, mode, signedIn, streaming, people, signaling_url,
- profileForm, automation,
+ profileForm, automation, limitsInfo,
// The page that frames the embed may hand it a Drive ticket (public/panel.js); the owner's page offers to put
// The page that frames the embed may hand it a Drive ticket (public/panel.js); the owner's page offers to put
// the panel under their Live stream.
// the panel under their Live stream.
parentOrigins: mode === 'embed' ? ((config.embed && config.embed.origins) || []) : [],
parentOrigins: mode === 'embed' ? ((config.embed && config.embed.origins) || []) : [],
@@ -1,7 +1,8 @@
@@ -1,7 +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.
+// 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).
const assert = require('assert');
const assert = require('assert');
const { boot, check, done } = require('./helpers/app');
const { boot, check, done } = require('./helpers/app');
@@ -62,7 +63,9 @@ const TITLES = ['OpenVibe.Bot — drive your robot from any browser', 'Build for
@@ -62,7 +63,9 @@ const TITLES = ['OpenVibe.Bot — drive your robot from any browser', 'Build for
}
}
TITLES.forEach((title, i) => assert.ok(body.includes(`### ${title}`), `llms-full.txt has ${title}`));
TITLES.forEach((title, i) => assert.ok(body.includes(`### ${title}`), `llms-full.txt has ${title}`));
PATHS.forEach((p) => assert.ok(body.includes(`URL: ${BASE}${p}`), `llms-full.txt has the absolute URL for ${p}`));
PATHS.forEach((p) => assert.ok(body.includes(`URL: ${BASE}${p}`), `llms-full.txt has the absolute URL for ${p}`));
- for (const bad of ['/api', '/internal', '/panel/']) assert.ok(!body.includes(bad), `llms-full.txt does not name ${bad}`);
+ for (const bad of ['/internal', '/panel/']) assert.ok(!body.includes(bad), `llms-full.txt does not name ${bad}`);
+ // The account id and a robot's real limits are named on purpose; no other API path is.
+ assert.ok(!/\/api\/v1\/(?!me\b|robots\b)/.test(body), 'llms-full.txt names no API path but /me and /robots');
});
});
await t.close();
await t.close();
new file mode 100644
new file mode 100644
@@ -0,0 +1,69 @@
@@ -0,0 +1,69 @@
+'use strict';
+// PR1: a robot's owner and the limits it really enforces, and GET /me for the account ID.
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/app');
+
+(async () => {
+ const t = await boot();
+ const alex = t.network.newUser('alex');
+ const invited = t.network.newUser('invited');
+ const stranger = t.network.newUser('stranger');
+
+ await check('GET /me answers the token\'s own account', async () => {
+ const me = await t.call('GET', '/api/v1/me', { user: alex });
+ assert.strictEqual(me.status, 200, me.text);
+ assert.strictEqual(me.json.subject, alex.subject);
+ assert.strictEqual(me.json.username, 'alex');
+ assert.strictEqual(me.json.display_name, 'Alex');
+ assert.strictEqual(me.json.kind, 'user');
+ assert.strictEqual((await t.call('GET', '/api/v1/me', { token: null })).status, 401);
+ });
+
+ await check('an app token answers for its own subject, never a person\'s', async () => {
+ const r = await t.call('GET', '/api/v1/me', { sub: 'svc:actor' });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.json.subject, 'svc:actor');
+ assert.strictEqual(r.json.kind, 'service');
+ assert.strictEqual(r.json.username, null);
+ });
+
+ await check('GET /robots/:id carries owner_subject, effective_limits and limits_source', async () => {
+ const { robot } = await t.robot(alex, { profile_id: 'looi', limits: { max_speed: 0.4 } });
+ const r = await t.call('GET', `/api/v1/robots/${robot.id}`, { user: alex });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.json.robot.owner_subject, alex.subject);
+ assert.deepStrictEqual(r.json.robot.effective_limits, { max_speed: 0.4, max_turn: 0.6, max_command_ms: 300, heartbeat_ms: 1000 });
+ assert.deepStrictEqual(r.json.robot.limits_source.profile, { max_speed: 0.5, max_turn: 0.6, max_command_ms: 300, heartbeat_ms: 1000 });
+ assert.deepStrictEqual(r.json.robot.limits_source.robot, { max_speed: 0.4 }, 'only the owner\'s overrides');
+ });
+
+ await check('a robot with no limits{} says the profile\'s effective values, not {}', async () => {
+ const { robot } = await t.robot(alex, { profile_id: 'looi' });
+ const r = await t.call('GET', `/api/v1/robots/${robot.id}`, { user: alex });
+ assert.deepStrictEqual(r.json.robot.limits, {});
+ assert.deepStrictEqual(r.json.robot.effective_limits, { max_speed: 0.5, max_turn: 0.6, max_command_ms: 300, heartbeat_ms: 1000 });
+ assert.deepStrictEqual(r.json.robot.limits_source.robot, {});
+ });
+
+ await check('the list carries the same fields, for a member and for a service', async () => {
+ const { robot } = await t.robot(alex, { profile_id: 'looi', limits: { max_turn: 0.2 } });
+ await t.call('POST', `/api/v1/robots/${robot.id}/operators`, { user: alex, body: { subject: invited.subject, role: 'operator' } });
+ const seen = await t.call('GET', `/api/v1/robots/${robot.id}`, { user: invited });
+ assert.strictEqual(seen.status, 200);
+ assert.strictEqual(seen.json.role, 'operator');
+ assert.strictEqual(seen.json.robot.owner_subject, alex.subject);
+ assert.strictEqual(seen.json.robot.effective_limits.max_turn, 0.2);
+ const list = await t.call('GET', '/api/v1/robots', { user: alex });
+ const one = list.json.robots.find((x) => x.id === robot.id);
+ assert.ok(one);
+ assert.strictEqual(one.owner_subject, alex.subject);
+ assert.strictEqual(one.effective_limits.max_turn, 0.2);
+ const svc = await t.call('GET', `/api/v1/robots?owner=${alex.subject}`, { cap: ['bot.robot.read'] });
+ assert.strictEqual(svc.status, 200, svc.text);
+ assert.strictEqual(svc.json.robots.find((x) => x.id === robot.id).effective_limits.max_speed, 0.5);
+ assert.strictEqual((await t.call('GET', '/api/v1/robots', { user: stranger })).json.robots.length, 0);
+ });
+
+ await t.close();
+ done();
+})();