openvibers/openvibe.bot Commits

A robot's owner and the limits it really enforces, and GET /me (#305)

OP OpenVibers committed 7 days ago · ed8746684a · 8 files, +186 −16

GET /robots and GET /robots/:id now carry owner_subject, effective_limits
(max_speed, max_turn, max_command_ms, heartbeat_ms) and limits_source (profile
and robot overrides), so an integrator never reads limits:{} as no limits.
GET /api/v1/me answers the token's account id. The owner's panel shows the
effective limits with their source and the account id with a Copy button.
Docs and llms.txt name both.

Changes

ddiff --git a/docs/protocol.md b/docs/protocol.md
iindex a3ec634..6e88d12 100644
---- a/docs/protocol.md
++++ b/docs/protocol.md
@@@ -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,
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`
--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
ddiff --git a/public/panel.js b/public/panel.js
iindex dfdcbf9..9cc570a 100644
---- a/public/panel.js
++++ b/public/panel.js
@@@ -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);
}, () => {});
}
});
ddiff --git a/server/api/v1.js b/server/api/v1.js
iindex 89466c4..d49a1bf 100644
---- a/server/api/v1.js
++++ b/server/api/v1.js
@@@ -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, subm
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 ────────────────────────────────────────────────────────────────────────────────────
++ /**
++ * 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, subm
owner: 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);
ddiff --git a/server/web/docs.js b/server/web/docs.js
iindex 8a71bcc..ec9b87f 100644
---- a/server/web/docs.js
++++ b/server/web/docs.js
@@@ -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 });
}
ddiff --git a/server/web/render.js b/server/web/render.js
iindex fc5e64b..f53a13e 100644
---- a/server/web/render.js
++++ b/server/web/render.js
@@@ -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',
ddiff --git a/server/web/routes.js b/server/web/routes.js
iindex 5b20e7c..3dde3fb 100644
---- a/server/web/routes.js
++++ b/server/web/routes.js
@@@ -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, limi
const 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) || []) : [],
ddiff --git a/test/llms.test.js b/test/llms.test.js
iindex f00f641..abef3d7 100644
---- a/test/llms.test.js
++++ b/test/llms.test.js
@@@ -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();
ddiff --git a/test/robot-limits.test.js b/test/robot-limits.test.js
nnew file mode 100644
iindex 0000000..af99098
---- /dev/null
++++ b/test/robot-limits.test.js
@@@ -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();
++})();