openvibers/openvibe.bot Commits
Add operator invites by @username and the public turn queue to the panel (#32)
Changes
@@@ -16,6 +16,7 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato- The signed-in pages (`server/web/routes.js`): `/robots` to list and add robots, `/pair/:id` for a pairing code and the installer command, and `/panel/:id`, the panel rendered from the profile. A `sim.rover` robot is driven by an in-process simulator (`server/sim`), so the panel works before any hardware exists. It gets no jobs, and it is not counted as a device or reported online in the outbox. The panel (`public/panel.js`, no build step) drives with a touch joystick, the keyboard or a gamepad, shows a latency meter and one camera tile per camera the profile lists; the camera stays a placeholder until OpenRe.Stream can play a WHIP-published stream back to a browser.- Server-side ONVIF cameras (`camera.onvif`, `mapping.driver: onvif`): an in-process connector beside the simulator (`server/onvif`) attaches a camera configured with `BOT_ONVIF_CAMERAS`, writes a real `kind: server` device row, translates a `ptz` command into ONVIF `ContinuousMove`/`Stop` (with the profile's deadline as a deadman) and carries the camera's `GetStatus` readout in telemetry. Its credentials are secret references only (`username_ref`/`password_ref`).- The command audit (`command_audit`, kept 30 days) and the `bot.*` outbox events.++- Operators and viewers from the panel: the owner adds a person to a robot by `@username` (OpenVibe.Network resolves the name to a subject) and removes them again, each listed on the panel's People card. On an open-queue robot a signed-in visitor sees their turn — or their place in line — and can leave the queue, which promotes the next person at once (`POST /robots/:id/operators`, `POST /robots/:id/operators/:subject/remove`, `POST /robots/:id/queue/leave`, all plain forms that work without JavaScript).- The robot's streaming and recording toggles, off by default: `media` records live sessions to OpenVibe.Media and `live` shows them on the owner's OpenVibe.Live channel. They live only on the robot's OpenRe stream (`recording_mode`, `mirror_to_live`); Bot stores no copy and the owner, not an operator, flips them (`GET`/`POST /api/v1/robots/:id/streaming`).- Dispatching `platform.job@1` jobs to a paired Node over `/device` (`server/jobs/index.js` over `server/jobs/dispatch.js`; the internal `bot.job.dispatch` API is `POST /api/v1/jobs`, `POST /jobs/:id/cancel`, `GET /jobs/:id`) and metering them per wall-clock second (`run_jobs`, `run_usage_outbox`; docs/protocol.md §1.2).@@@ -36,7 +37,7 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato- PostgreSQL 18: robots, devices, profile rows, pairing codes, the command audit and the event outbox; `migrations/` applies on boot.- Valkey (`VALKEY_URL`, optional): the shared per-actor limit counters; unset, limits are counted per process.--- OpenVibe.Network: user and service tokens, node tokens, and pairing when the authority is `network`.++- OpenVibe.Network: user and service tokens, node tokens, pairing when the authority is `network`, and identity resolution (a `@username` to a subject, and subjects to names, for the panel's People card).- OpenRe.Stream: the WHIP ingest base (`BOT_WHIP_BASE`) devices publish to.- OpenVibe.Billing: job usage readings go to `billing.usage.record` (`BOT_BILLING_URL`, `BOT_BILLING_TOKEN`); unset, they wait in `run_usage_outbox`.- `openvibe-contracts` v0.96.0, `openvibe-sdk` v0.26.0 and `openvibe-shared` v2.5.0 (package.json).@@@ -39,6 +39,7 @@"public robot-profile catalogue (GET /profiles, /profiles/:id); robots are private to their owner, with public control only as a timed turn queue","per-actor limits on capability-boundary writes (openvibe-sdk/limits; shared through Valkey when VALKEY_URL is set)","signed-in pages: /robots (list and add), /pair/:id (the code and the one-paste installer command) and /panel/:id, rendered from the robot's profile and driven over /control; a sim.rover robot is driven by an in-process simulated device",++ "the panel's People card: the owner adds an operator or viewer by @username (OpenVibe.Network resolves the name to a subject) and removes them again, shown with Network's names; a signed-in visitor on an open-queue robot sees their turn or their place in line and can leave the queue, promoting the next person (POST /robots/:id/operators and …/operators/:subject/remove, POST /robots/:id/queue/leave)","panel input: a touch joystick, the keyboard and a gamepad (Gamepad API), each held control re-sent while held and zeroed at once on release; a latency meter (round-trip median and telemetry age); one camera tile per camera the profile lists (a placeholder until OpenRe can play a WHIP stream back); a live online indicator on /pair/:id read from /control","platform.job@1 jobs dispatched to a paired Node over /device (platform.job-frame@1: resent unacked on reconnect, only to an advertised runtime class) and metered per second into run_usage_outbox for billing.usage.record","embed opt-in: robots.embed_public (migration 0005), set only by the owner with POST /robots/:id/embed, web-only (not in /api/v1); BOT_EMBED_ORIGINS is the validated frame-ancestors allow-list for the embeddable panel (default OpenVibe.Live)",@@@ -330,8 +330,9 @@ needs an explicit owner or operator role — a no-role caller is refused `bot.no| `error` | `code, detail` | a frame or join the server refused |`robot_state.queue` (only for a `queue` robot) is--`{ robot_id, subject, active, turn_ends_at, turn_subject, position, budget, used }` — a waiting--person's `position` is 1-based (0 while they hold the turn).++`{ robot_id, subject, active, turn_ends_at, position, budget, used }` — `subject` is the reader's own,++never another person's, so a waiting person's `position` is 1-based (0 while they hold the turn) and no++member ever learns the active driver's subject.### Examples@@@ -470,7 +471,11 @@ node token, and a service token is judged on `bot.job.dispatch` alone.Not in `/api/v1`: `GET /api/health`, `GET /api/ready`, `GET /metrics`, `GET /release.json`, the`/auth/*` SSO routes, `GET /` (a one-line text health placeholder), `GET /install` (a 302 to the installer),--and the signed-in pages `GET|POST /robots`, `GET /pair/:id`, `GET /panel/:id` and `GET /panel/panel.{js,css}`++and the signed-in pages `GET|POST /robots`, `GET /pair/:id`, `GET /panel/:id`, `GET /panel/panel.{js,css}`,++the panel's owner-only forms `POST /robots/:id/operators` (body `username`, `role` `operator|viewer`; a leading++`@` is stripped; an unknown name is `422 bot.unknown_user` with `No OpenVibe account is called @<name>`) and++`POST /robots/:id/operators/:subject/remove` (the owner row cannot be removed), and `POST /robots/:id/queue/leave`++(any signed-in person; the next in line is promoted)(`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`).@@@ -245,3 +245,12 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf.kit-parts summary::before { content: ''; width: 0.45rem; height: 0.45rem; margin: 0 0.4rem 0 0.15rem; border-right: 2px solid var(--muted); border-bottom: 2px solid var(--muted); transform: rotate(-45deg); transition: transform 0.12s; }.kit-parts[open] summary::before { transform: rotate(45deg); }@media (prefers-reduced-motion: reduce) { .kit-parts summary::before { transition: none; } }++++/* The owner's People card and the turn-queue strip. */++.people-list { list-style: none; margin: 0 0 0.75rem; padding: 0; display: grid; gap: 0.4rem; }++.person { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; padding: 0.5rem 0.65rem; border: 1px solid var(--line); border-radius: 10px; background: var(--raised); }++.person .who { display: grid; min-width: 0; } .person .who b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .person .who small { color: var(--muted); font-size: 0.82rem; }++.people-add { display: flex; flex-wrap: wrap; gap: 0.5rem; } .people-add input { flex: 1 1 10rem; min-width: 0; padding: 0 0.75rem; border: 1px solid var(--line); background: var(--bg); color: var(--text); } .people-add select { padding: 0 0.5rem; border: 1px solid var(--line); background: var(--bg); color: var(--text); }++.turn { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem 0.9rem; margin-top: 0.6rem; padding: 0.5rem 0.75rem; border: 1px solid var(--accent); border-radius: 10px; background: color-mix(in srgb, var(--accent) 12%, transparent); }++.turn-state { font-weight: 700; } .turn-meta { color: var(--muted); font-variant-numeric: tabular-nums; } .turn form { margin-left: auto; }++.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }@@@ -477,6 +477,33 @@}}++ // ── The turn queue strip (a `queue` robot's signed-in non-member) ─────────────────────────────────++ const turnEl = $('[data-turn]');++ const turnStateEl = $('[data-turn-state]');++ const turnMetaEl = $('[data-turn-meta]');++ let turnTimer = null;++ const stopTurnTimer = () => { if (turnTimer) { clearInterval(turnTimer); turnTimer = null; } };++ /** Paint the queue view from a state frame's `queue`: the active turn's countdown, a place in line, or hidden. */++ function paintTurn(queue) {++ stopTurnTimer();++ if (!turnEl) return;++ if (!queue || (!queue.active && !(queue.position > 0))) { turnEl.hidden = true; return; }++ turnEl.hidden = false;++ if (queue.active) {++ const ends = queue.turn_ends_at ? new Date(queue.turn_ends_at).getTime() : null;++ const paintLeft = () => {++ const left = ends != null ? Math.max(0, Math.ceil((ends - Date.now()) / 1000)) : null;++ turnStateEl.textContent = 'Your turn';++ turnMetaEl.textContent = `${left == null ? '—' : `${left}s`} left · ${queue.used} of ${queue.budget} commands`;++ };++ paintLeft();++ if (ends != null) turnTimer = setInterval(paintLeft, 1000);++ } else {++ turnStateEl.textContent = `You're #${queue.position} in line`;++ turnMetaEl.textContent = '';++ }++ }++function onFrame(m) {if (m.type === 'joined') {joined = true; retry = 0;@@@ -484,8 +511,10 @@notice('');enableControls();paint(m.state);++ paintTurn(m.state && m.state.queue);} else if (m.type === 'robot_state') {paint(m.state);++ paintTurn(m.state && m.state.queue);} else if (m.type === 'command_result') {roundTrip(m.id, m.result);if (m.result === 'refused' && m.code !== 'bot.cooldown') notice(m.reason || m.code);@@@ -501,6 +530,7 @@ws.onmessage = (e) => { let m; try { m = JSON.parse(e.data); } catch { return; } onFrame(m); };ws.onclose = (e) => {releaseAll();++ stopTurnTimer();joined = false;enableControls();setOnline('disconnected', 'Reconnecting…');@@@ -22,7 +22,7 @@ const { http } = require('openvibe-contracts');const { isLoopbackDirect } = require('openvibe-shared/metrics');const { loadConfig } = require('./config');const { openDb } = require('./db');--const { createKeyProvider, createUserAuth, createNodePrincipals } = require('./network');++const { createKeyProvider, createUserAuth, createNodePrincipals, createIdentity } = require('./network');const { createOpenRe } = require('./openre/client');const { createBotOutbox } = require('./events/outbox');const { createUsageRelay } = require('./jobs/metering');@@@ -55,6 +55,7 @@ function createApp(opts = {}) {const usage = opts.usage || createUsageRelay({ db, config, fetchImpl: opts.billingFetch || fetchImpl, now, log });const hub = opts.hub || createRealtime({ config, keys, userAuth, log, now });const nodes = opts.nodes || createNodePrincipals(config, { fetchImpl });++ const identity = opts.identity || createIdentity(config, { fetchImpl });const openre = opts.openre !== undefined ? opts.openre : createOpenRe(config, { fetchImpl });const domain = createDomain({ db, config, outbox, link: hub, nodes, openre, now, log });hub.bindDomain(domain);@@@ -97,7 +98,7 @@ function createApp(opts = {}) {app.use('/api/v1', express.json({ limit: '64kb' }), (req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); }, apiAuth.middleware, v1Router({ domain, apiAuth, limits, hub, jobs, config }));app.use('/auth', createSessionRoutes(config, userAuth, { fetchImpl }));app.use(viewerMiddleware(userAuth));-- app.use(createWebRoutes(config, { domain, sim, onvif, limits, log }));++ app.use(createWebRoutes(config, { domain, sim, onvif, limits, identity, hub, log }));app.use((req, res) => {if (req.path.startsWith('/api/') || req.path.startsWith('/internal/')) return http.sendProblem(res, 404, 'not_found', { ctx: req.ov });@@@ -101,13 +101,26 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =async function addOperator(robotId, subject, role, addedBy) {if (!['owner', 'operator', 'viewer'].includes(role)) fail(422, 'bot.invalid_role', 'role must be owner, operator or viewer');if (role === 'owner') fail(422, 'bot.invalid_role', 'ownership is not transferred by adding an operator');++ const before = await roleOf(robotId, subject);await db.query(`INSERT INTO robot_operators (robot_id, subject, role, added_by, created_at) VALUES ($1, $2, $3, $4, $5)ON CONFLICT (robot_id, subject) DO UPDATE SET role = EXCLUDED.role, added_by = EXCLUDED.added_by`,[robotId, subject, role, addedBy || null, iso(now())]);++ if (before !== role) membershipChanged(robotId, subject, 'your role on this robot changed');}async function removeOperator(robotId, subject) {++ const before = await roleOf(robotId, subject);await db.query(`DELETE FROM robot_operators WHERE robot_id = $1 AND subject = $2 AND role <> 'owner'`, [robotId, subject]);++ if (before && before !== 'owner') membershipChanged(robotId, subject, 'you were removed from this robot');++ }++ /**++ * A membership change: the person's live /control sockets on the robot are dropped (so their join++ * re-derives the role) and every panel of the robot is re-broadcast. The one place both the web form++ * routes and the v1 API reach, since both change membership through addOperator/removeOperator.++ */++ function membershipChanged(robotId, subject, reason) {++ if (link && link.dropSubject) link.dropSubject(robotId, subject, reason);++ if (link && link.broadcast) link.broadcast(robotId);}// ── Robots ────────────────────────────────────────────────────────────────────────────────────@@@ -600,6 +613,17 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =await currentTurn(robotId);return queueState(robotId, subject);}++ /**++ * Leave the queue: remove the person's row (waiting or the active turn) and, when their turn ends,++ * promote the oldest waiting one. Returns the leaver's view of the queue (null/mostly empty when gone).++ */++ async function leaveQueue(robotId, subject) {++ const robot = await getRobot(robotId);++ if (!robot) fail(404, 'bot.robot_not_found', 'no such robot');++ await db.query('DELETE FROM robot_queue WHERE robot_id = $1 AND subject = $2', [robotId, subject]);++ await currentTurn(robotId);++ return queueState(robotId, subject);++ }async function queueState(robotId, subject) {const robot = await getRobot(robotId);if (!robot) return null;@@@ -613,7 +637,6 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =return {robot_id: robotId, subject: subject || null, active: !!(active && active.subject === subject),turn_ends_at: active ? iso(new Date(active.turn_ends_at).getTime()) : null,-- turn_subject: active ? active.subject : null,position: active && active.subject === subject ? 0 : position,budget: turnBudget(robot),used: active && active.subject === subject ? active.commands_used : 0,@@@ -865,7 +888,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =devices: { byCredential, bindNode, issuePublishKey, revokeVideo, updateDeclared, revokeNode, ensureServerDevice, get: getDevice, listForRobot: listDevicesForRobot, rotate: rotateDevice, revoke: revokeDevice, touchSeen, setOnline },streaming: { get: streaming, set: setStreaming },estop: { set: setEstop, clear: clearEstop },-- queue: { join: joinQueue, state: queueState, currentTurn, consume: consumeTurn, sweep: sweepQueues },++ queue: { join: joinQueue, leave: leaveQueue, state: queueState, currentTurn, consume: consumeTurn, sweep: sweepQueues },audit: { record: auditCommand, list: listAudit, listPage: listAuditPage, prune: pruneAudit },control: { prepare, allowedFor, effectiveLimits, deviceLimits, buildValue, DEFAULT_ALLOW, KINDS },};@@@ -9,6 +9,8 @@* nodes Network's node principals (machines paired for Bot): POST /internal/node-pairings (a pairing* code, BOT_PAIRING_AUTHORITY=network), GET /internal/node-principals/:id and POST …/:id/revoke with* Bot's own svc:bot token (audience openvibe.network, network.node.manage).++ * identity Network's people (identity.subject.resolve): resolve a username to a subject and look up the++ * names of subjects the robot's owner added.*/const crypto = require('crypto');const jwt = require('jsonwebtoken');@@@ -109,4 +111,74 @@ function createNodePrincipals(config, { fetchImpl = globalThis.fetch } = {}) {};}--module.exports = { createKeyProvider, createUserAuth, createNodePrincipals };++/**++ * Network's identity resolution for people (usernames ↔ subjects), with Bot's own svc:bot token++ * (audience openvibe.network, identity.subject.resolve — Network grants Bot this).++ * byUsername(name) → GET /internal/identity/resolve?username=<name> → { subject, username, display_name },++ * or null when Network knows no such person (a 404 whose problem code is++ * identity.subject_not_found).++ * names(subjectIds) → POST /internal/identity/resolve-batch { subject_ids } → Map(subject → { username,++ * display_name }), skipping the ids Network does not know. Answers are cached in memory++ * for five minutes, at most 2000 subjects.++ * Any other failure throws 503 bot.network_unavailable.++ */++function createIdentity(config, { fetchImpl = globalThis.fetch, now = () => Date.now() } = {}) {++ const tokens = serviceAuth.createTokenClient({++ tokenUrl: `${config.network.internalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,++ audience: 'openvibe.network', scope: 'identity.subject.resolve', fetchImpl,++ });++ const TTL_MS = 5 * 60 * 1000;++ const MAX_ENTRIES = 2000;++ const cache = new Map(); // subject → { username, display_name, at }++ async function call(method, path, body) {++ let res;++ try {++ res = await fetchImpl(`${config.network.internalUrl}${path}`, {++ method, headers: { ...(await tokens.authHeaders()), Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}) },++ ...(body ? { body: JSON.stringify(body) } : {}), signal: AbortSignal.timeout(8000),++ });++ } catch (e) {++ throw new BotError(503, 'bot.network_unavailable', `Network did not answer to resolve a person: ${e.message}`);++ }++ if (res.status === 401) tokens.invalidate();++ const out = await res.json().catch(() => null);++ // Only Network's own "no such person" is null. A 404 from a Network that lacks the route (or any++ // other 404) and a 400 identity.bad_request are an outage, never "no such account".++ if (method === 'GET' && res.status === 404 && out && out.code === 'identity.subject_not_found') return null;++ if (!res.ok || !out) throw new BotError(503, 'bot.network_unavailable', `Network answered ${res.status} for identity`);++ return out;++ }++ function remember(subject, p) {++ if (!subject || !p || typeof p.username !== 'string' || !p.username) return;++ if (cache.size >= MAX_ENTRIES) cache.delete(cache.keys().next().value);++ cache.set(subject, { username: p.username, display_name: typeof p.display_name === 'string' && p.display_name ? p.display_name : p.username, at: now() });++ }++ async function byUsername(name) {++ const out = await call('GET', `/internal/identity/resolve?username=${encodeURIComponent(name)}`);++ if (!out || typeof out.subject !== 'string') return null;++ remember(out.subject, out);++ return { subject: out.subject, username: out.username, display_name: out.display_name || out.username };++ }++ async function names(subjectIds) {++ const ids = [...new Set((subjectIds || []).filter((s) => typeof s === 'string' && s))];++ const found = new Map();++ const missing = [];++ for (const id of ids) {++ const hit = cache.get(id);++ if (hit && now() - hit.at < TTL_MS) found.set(id, { username: hit.username, display_name: hit.display_name });++ else missing.push(id);++ }++ for (let i = 0; i < missing.length; i += 500) {++ const out = await call('POST', '/internal/identity/resolve-batch', { subject_ids: missing.slice(i, i + 500) });++ for (const [subject, p] of Object.entries((out && out.results) || {})) {++ if (!p || typeof p.username !== 'string') continue;++ remember(subject, p);++ found.set(subject, { username: p.username, display_name: p.display_name || p.username });++ }++ }++ return found;++ }++ return { byUsername, names };++}++++module.exports = { createKeyProvider, createUserAuth, createNodePrincipals, createIdentity };@@@ -593,6 +593,22 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Datfor (const c of [...set]) if (c.watcher) { unsubscribe(c); try { c.ws.close(4003, reason); } catch { /* gone */ } }}++ /**++ * A signed-in person's access to the robot changed (removed, or their role moved): close every++ * non-watcher socket of theirs on this robot with 4003, so the client reconnects and its join++ * re-derives the role. A watcher is anonymous and is never that subject.++ */++ function dropSubject(robotId, subject, reason = 'your access to this robot changed') {++ const set = subsByRobot.get(robotId);++ if (!set || !subject) return;++ for (const c of [...set]) {++ if (c.watcher || c.subject !== subject) continue;++ unsubscribe(c);++ c.robotId = null;++ try { c.ws.close(4003, reason); } catch { /* gone */ }++ }++ }++// ── State and helpers ─────────────────────────────────────────────────────────────────────────function deviceForRobot(robotId) {let sim = null;@@@ -705,7 +721,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => DatbindDomain(d) { domain = d; },bindJobs(j) { jobs = j; },handleUpgrade, attachSim, detachSim,-- isOnline, deviceState, sendToDevice, sendToRobotDevices, broadcast, closeDevice, robotState, refreshConfig, pushEstop, closeWatchers, operatorCommand,++ isOnline, deviceState, sendToDevice, sendToRobotDevices, broadcast, closeDevice, robotState, refreshConfig, pushEstop, closeWatchers, dropSubject, operatorCommand,// Real devices only: a simulator is not a machine anyone runs.onlineCount() { let n = 0; for (const c of deviceConns.values()) if (c.online && !c.sim) n++; return n; },devices() { return [...deviceConns.values()].filter((c) => !c.sim).map((c) => ({ device_id: c.device.id, online: c.online })); },@@@ -7,8 +7,9 @@* code here. Every value is HTML-escaped; nothing is inline (the CSP is `default-src 'self'`), so the client* (public/panel.js) reads what it needs from data-* attributes, and the icons are inline SVG markup.*-- * renderPanel({ robot, profile, role, allowed_commands, holdResendMs, mode, signedIn })-- * mode 'embed': the framed panel (no topbar, no owner form, links open a new tab)++ * renderPanel({ robot, profile, role, allowed_commands, holdResendMs, mode, signedIn, streaming, people })++ * mode 'embed': the framed panel (no topbar, no owner form, links open a new tab);++ * `people` (owner page only) the non-owner members for the People card* renderEmbedRefused({ robotId }) the framed answer when the visitor may not see the robot* renderWidget(widget, { profile, allowed_commands })* renderRobotsPage({ robots, profiles, error, values })@@@ -254,7 +255,42 @@ ${toggle('live', s.live && s.live.on, 'Show on my OpenVibe.Live channel', 'Lets/** A link out of a frame: always a new tab, never with a handle back to the framing page. */const outLink = (href, text, cls = 'button') => `<a class="${cls}" href="${esc(href)}" target="_blank" rel="noopener">${esc(text)}</a>`;--function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs = 150, mode = 'page', signedIn = true, streaming = null }) {++/**++ * The owner's People card: who may drive (operators) and who may watch (viewers), each with Network's name++ * (or the subject id when Network was not reachable while the page was drawn), and the @username add form.++ * Rendered only for the owner's own page.++ */++function peopleCard(robot, people) {++ const roleName = (r) => (r === 'viewer' ? 'Viewer' : 'Operator');++ const row = (p) => {++ const remove = `<form method="post" action="/robots/${esc(robot.id)}/operators/${esc(encodeURIComponent(p.subject))}/remove"><button type="submit" class="quiet">Remove</button></form>`;++ const who = p.username++ ? `<b>${esc(p.display_name)}</b><small>@${esc(p.username)} · ${roleName(p.role)}</small>`++ : `<b>${esc(p.subject)}</b><small>name unavailable</small>`;++ return `<li class="person"><span class="who">${who}</span>${remove}</li>`;++ };++ const queue = robot.access_policy === 'queue' ? ' Anyone signed in can also take a turn: this robot uses the open queue.' : '';++ return `<section class="setting-card people" aria-labelledby="people-h"><h2 id="people-h">People</h2>++<p class="setting-note">Operators drive and can press the e-stop; viewers watch.${queue}</p>++<ul class="people-list">++<li class="person"><span class="who"><b>You</b><small>Owner</small></span></li>++${(people || []).map(row).join('\n')}++</ul>++<form class="people-add" method="post" action="/robots/${esc(robot.id)}/operators"><label class="sr-only" for="people-name">Username</label><input id="people-name" name="username" placeholder="@username" required maxlength="41" autocomplete="off"><select name="role" aria-label="Role"><option value="operator">Operator</option><option value="viewer">Viewer</option></select><button type="submit">Add</button></form>++</section>`;++}++++/**++ * The turn strip a signed-in non-member sees on a `queue` robot's panel: the active turn's countdown or their++ * place in line, and a plain leave form that works without JavaScript (public/panel.js unhides it and paints++ * the state). Hidden until a state frame says there is a queue view for this person.++ */++function turnStrip(robot, role) {++ if (robot.access_policy !== 'queue' || role !== 'queue') return '';++ return `<div class="turn" data-turn hidden><span class="turn-state" data-turn-state></span><span class="turn-meta" data-turn-meta></span><form method="post" action="/robots/${esc(robot.id)}/queue/leave" data-turn-leave><button type="submit" class="quiet">Leave the queue</button></form></div>`;++}++++function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs = 150, mode = 'page', signedIn = true, streaming = null, people = null }) {const embed = mode === 'embed';const latched = !!(robot.estop && robot.estop.latched);const widgets = (profile.widgets || []).map((w, i) => ({ w, i }))@@@ -270,13 +306,14 @@ ${embed ? '' : topbar(`<span class="crumb">${esc(robot.name)}</span>`)}<div class="panel-head"><h1>${esc(robot.name)}</h1><p class="meta"><span class="pill online" data-online-pill data-state="connecting"><span class="dot"></span><span data-online-label>Connecting…</span></span> <span class="pill">${esc(profile.name || robot.profile_id)}</span> <span class="pill role" data-role-label>${esc(ROLE_LABEL[role] || role)}</span></p>++${turnStrip(robot, role)}</div>${embed && role === 'watcher' && !signedIn ? `<p class="embed-sign-in">${outLink(`/panel/${robot.id}`, 'Sign in to control')}</p>` : ''}<p class="notice" data-notice role="alert" hidden></p><div class="widgets">${widgets.join('\n')}</div>--${role === 'owner' && !embed ? `<div class="owner-settings">${embedForm(robot)}${streamingForm(robot, streaming)}</div>` : ''}++${role === 'owner' && !embed ? `<div class="owner-settings">${embedForm(robot)}${streamingForm(robot, streaming)}${people ? peopleCard(robot, people) : ''}</div>` : ''}</main>`;return page(robot.name, body, { scripts: ['/panel/panel.js'], bodyClass: embed ? 'embed-page' : 'panel-page' });}@@@ -15,6 +15,9 @@* otherwise 403 with a link out. Never redirects to sign in; framed only by the* BOT_EMBED_ORIGINS allow-list (CSP frame-ancestors), every other page by itself* 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/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* GET /panel/panel.js, /panel/panel.css the panel client and its sheet (public/, no build step)** Not signed in → 302 to /auth/login?next=<the page>. No access → 403 (bot.not_an_operator / bot.forbidden).@@@ -33,7 +36,7 @@ const VERSION = require('../../package.json').version;const PUBLIC = path.join(__dirname, '..', '..', 'public');const HOLD_RESEND_MS = 150;--function createWebRoutes(config, { domain = null, sim = null, onvif = null, limits = null, log = console } = {}) {++function createWebRoutes(config, { domain = null, sim = null, onvif = null, limits = null, identity = null, hub = null, log = console } = {}) {const r = express.Router();r.get('/', (req, res) => res.type('text/plain').send(`OpenVibe.Bot ${VERSION} — ok (devices, pairing and control; API under /api/v1)\n`));r.get('/robots.txt', (req, res) => res.type('text/plain').send('User-agent: *\nDisallow: /\n'));@@@ -175,6 +178,48 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limires.redirect(303, `/panel/${robot.id}`);}));++ // The owner adds a person to the robot by their OpenVibe username. Network resolves the @name to a subject;++ // an unknown name is 422 with that name in the detail. The owner (already the owner row) may not be added.++ r.post('/robots/:id/operators', ...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 add people');++ const role = String((req.body && req.body.role) || '');++ if (!['operator', 'viewer'].includes(role)) throw new BotError(422, 'bot.invalid_input', 'role must be operator or viewer');++ const username = String((req.body && req.body.username) || '').trim().replace(/^@/, '');++ if (!username) throw new BotError(422, 'bot.invalid_input', 'a username is required');++ if (!identity) throw new BotError(503, 'bot.network_unavailable', 'Network is not reachable to resolve that name');++ const user = await identity.byUsername(username);++ if (!user) throw new BotError(422, 'bot.unknown_user', `No OpenVibe account is called @${username}`);++ if (user.subject === me.subject) throw new BotError(422, 'bot.invalid_input', 'you are the owner of this robot');++ await domain.members.add(robot.id, user.subject, role, me.subject);++ res.redirect(303, `/panel/${robot.id}`);++ }));++++ // The owner removes a person. The domain's delete never touches the owner row.++ r.post('/robots/:id/operators/:subject/remove', ...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 remove people');++ await domain.members.remove(robot.id, req.params.subject);++ res.redirect(303, `/panel/${robot.id}`);++ }));++++ // Leave the turn queue: anyone signed in, whether or not they are a member; the panels are told at once.++ r.post('/robots/:id/queue/leave', ...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');++ await domain.queue.leave(robot.id, me.subject);++ if (hub) hub.broadcast(robot.id);++ res.redirect(303, `/panel/${robot.id}`);++ }));++/** As the /control join decides: a member's role, or a place in the queue on a `queue` robot; else null. */async function memberRole(robot, me) {const role = await domain.members.roleOf(robot.id, me.subject);@@@ -188,14 +233,31 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limiconst streaming = role === 'owner' && mode === 'page'? await domain.streaming.get(robot.id).catch(() => ({ available: false, reason: 'unavailable', media: { on: false }, live: { on: false } })): null;++ // The owner's People card: every member but the owner, with Network's name for each. A Network that++ // does not answer leaves the subject id and a note (see renderPanel) instead of failing the panel.++ const people = role === 'owner' && mode === 'page' ? await peopleFor(robot) : null;res.type('html').send(renderPanel({-- robot: presentForPage(robot), profile, role, mode, signedIn, streaming,++ robot: presentForPage(robot), profile, role, mode, signedIn, streaming, people,allowed_commands: role === 'watcher' ? [] : domain.control.allowedFor(robot, role, profile),// A held control is re-sent well inside the device's deadline, so it never stops between two frames.holdResendMs: Math.max(50, Math.min(HOLD_RESEND_MS, Math.floor(maxCommandMs / 2))),}));}++ /** The non-owner members with their Network names, or null names when Network cannot be asked. */++ async function peopleFor(robot) {++ const members = (await domain.members.list(robot.id)).filter((m) => m.subject !== robot.owner_subject);++ let names = new Map();++ if (members.length && identity) {++ try { names = await identity.names(members.map((m) => m.subject)); }++ catch (e) { log.warn(`[Bot] names for ${robot.id}: ${e.message}`); }++ }++ return members.map((m) => {++ const n = names.get(m.subject);++ return { subject: m.subject, role: m.role, username: n ? n.username : null, display_name: n ? n.display_name : null };++ });++ }++r.get('/panel/:id', page(async (req, res) => {const me = requireUser(req);const robot = await domain.robots.get(req.params.id);@@@ -4,7 +4,10 @@** startNetwork() JWKS and user/service JWTs (signUser, signService, newUser); node principals as* GET /internal/node-principals/:id and POST …/:id/revoke answer them (addNode, signNode,-- * nodes, revokes), scoped to the calling service like Network's own routes++ * nodes, revokes), scoped to the calling service like Network's own routes. Identity++ * resolution for people (identity.subject.resolve): GET /internal/identity/resolve?username=++ * and POST /internal/identity/resolve-batch, over the users newUser registers; failIdentity++ * makes them answer a failure (Network down).* startEvents() POST /api/v1/events recording what Bot's outbox relays; GET /api/health* startOpenRe() OpenRe.Stream's stream routes Bot calls (server/api/v1.js there): GET /api/v1/streams?external_ref=,* GET /api/v1/streams/:id, POST /api/v1/streams, PATCH /api/v1/streams/:id (recording_mode /@@@ -75,9 +78,24 @@ async function startNetwork() {let pairingFailure = null;const failPairings = (status) => { pairingFailure = status; };++ // Identity resolution for people (identity.subject.resolve). `users` is what newUser registered;++ // failIdentity(status) makes both routes answer that status instead (Network down).++ const users = new Map(); // subject → { subject, username, display_name }++ const byName = new Map(); // lowercased username → subject++ let identityFailure = null;++ function rememberUser(u) { users.set(u.subject, u); byName.set(String(u.username).toLowerCase(), u.subject); }function newUser(username) {-- return { subject: ids.newId('user'), username, display_name: username[0].toUpperCase() + username.slice(1) };++ const u = { subject: ids.newId('user'), username, display_name: username[0].toUpperCase() + username.slice(1) };++ rememberUser(u);++ return u;}++ const identityResolver = (req) => {++ const h = String(req.headers.authorization || '');++ if (!h.startsWith('Bearer ')) return false;++ const r = serviceAuth.verifyServiceToken(h.slice(7), { publicKey: publicPem, issuer, audience: 'openvibe.network' });++ return r.ok && (r.claims.cap || []).includes('identity.subject.resolve');++ };++ const identityView = (u) => ({ subject: u.subject, username: u.username, display_name: u.display_name });const server = http.createServer(async (req, res) => {if (req.url === '/api/.well-known/jwks') return send(res, 200, { public_key: publicPem, algorithm: 'RS256' });@@@ -88,6 +106,25 @@ async function startNetwork() {const cap = String(body.scope || '').split(/\s+/).filter(Boolean);return send(res, 200, { access_token: signService({ sub: `svc:${body.client_id}`, aud: [body.audience || 'openvibe.bot'], cap }), token_type: 'Bearer', expires_in: 300 });}++ const idn = new URL(req.url, 'http://network.test');++ if (idn.pathname === '/internal/identity/resolve' && req.method === 'GET') {++ if (!identityResolver(req)) return send(res, 401, { code: 'token.invalid' });++ if (identityFailure) return send(res, identityFailure, { code: 'identity.failed' });++ const subject = byName.get(String(idn.searchParams.get('username') || '').toLowerCase());++ const u = subject && users.get(subject);++ return u ? send(res, 200, identityView(u)) : send(res, 404, { code: 'identity.subject_not_found' });++ }++ if (idn.pathname === '/internal/identity/resolve-batch' && req.method === 'POST') {++ if (!identityResolver(req)) return send(res, 401, { code: 'token.invalid' });++ if (identityFailure) return send(res, identityFailure, { code: 'identity.failed' });++ const body = JSON.parse((await readBody(req)) || '{}');++ const results = {};++ for (const id of (Array.isArray(body.subject_ids) ? body.subject_ids : [])) {++ const u = users.get(String(id));++ results[String(id)] = u ? identityView(u) : null;++ }++ return send(res, 200, { results });++ }if (req.url === '/internal/node-pairings' && req.method === 'POST') {const service = nodeManager(req);if (!service) return send(res, 401, { code: 'token.invalid' });@@@ -114,7 +151,7 @@ async function startNetwork() {});const url = await listen(server);issuer = url;-- return { url, publicPem, signService, signUser, newUser, addNode, signNode, nodes, revokes, pairings, failPairings, close: () => new Promise((r) => server.close(r)) };++ return { url, publicPem, signService, signUser, newUser, addNode, signNode, nodes, revokes, pairings, failPairings, users, failIdentity: (status) => { identityFailure = status; }, close: () => new Promise((r) => server.close(r)) };}async function startEvents() {@@@ -103,6 +103,17 @@ const allowedFor = (profile, role) => [...(DEFAULT_ALLOW[role] || []).filter((k)}});++ await check('a person\'s subject is percent-encoded in the remove form action', () => {++ const p = profiles.find((x) => x.id === 'sim.rover');++ const subject = 'usr_a/b?c#d e&';++ const html = renderPanel({++ robot: robotFor(p), profile: p, role: 'owner', allowed_commands: [],++ people: [{ subject, role: 'operator', username: 'x', display_name: 'X' }],++ });++ assert.ok(html.includes(`/operators/${encodeURIComponent(subject)}/remove`), 'the subject is not percent-encoded in the URL');++ assert.ok(!html.includes(`/operators/${subject}/remove`), 'the raw subject is in the URL');++ });++const t = await boot();const alex = t.network.newUser('alex');const bob = t.network.newUser('bob');nnew file mode 100644@@@ -0,0 +1,246 @@++'use strict';++// Operator invites from the owner's panel (a @username resolved through Network) and the public turn queue's++// leave route (plan T15): the People card, the three form posts, and the turn strip on a queue robot's panel.++const fs = require('fs');++const path = require('path');++const assert = require('assert');++const { boot, check, done } = require('./helpers/app');++const { createIdentity } = require('../server/network');++++/**++ * A Network identity endpoint over a fake fetch, for the cache's own rules (subject keying, the TTL and the++ * 2000-entry bound) without a server. `now` drives the TTL; the token endpoint answers once.++ */++function identityStub() {++ let clock = 1_000_000;++ const users = new Map(); // subject → { subject, username, display_name }++ const names = new Map(); // lowercased username → subject++ const calls = { resolve: 0, batch: 0 };++ const reply = (status, body) => ({ ok: status >= 200 && status < 300, status, json: async () => body });++ const fetchImpl = async (url, opts = {}) => {++ const u = new URL(url);++ if (u.pathname === '/oauth/token') return reply(200, { access_token: 'tok', token_type: 'Bearer', expires_in: 300 });++ if (u.pathname === '/internal/identity/resolve' && opts.method === 'GET') {++ calls.resolve++;++ const subject = names.get(String(u.searchParams.get('username') || '').toLowerCase());++ return subject ? reply(200, users.get(subject)) : reply(404, { code: 'identity.subject_not_found' });++ }++ if (u.pathname === '/internal/identity/resolve-batch') {++ calls.batch++;++ const results = {};++ for (const id of (JSON.parse(opts.body || '{}').subject_ids || [])) results[id] = users.get(id) || null;++ return reply(200, { results });++ }++ return reply(404, { code: 'not_found' });++ };++ const identity = createIdentity(++ { network: { internalUrl: 'http://network.test' }, oauth: { clientId: 'bot', clientSecret: 'shh' } },++ { fetchImpl, now: () => clock });++ return {++ identity, calls,++ add: (username) => {++ const u = { subject: `usr_${username}`, username, display_name: username[0].toUpperCase() + username.slice(1) };++ users.set(u.subject, u);++ names.set(username.toLowerCase(), u.subject);++ return u;++ },++ now: () => clock,++ setNow: (t) => { clock = t; },++ };++}++++(async () => {++ const t = await boot();++ const alex = t.network.newUser('alex');++ const bob = t.network.newUser('bob');++ const carol = t.network.newUser('carol');++ const cookie = (user) => ({ Cookie: `ov_token=${t.network.signUser(user)}` });++ const get = (p, user, headers = {}) => fetch(t.base + p, { redirect: 'manual', headers: { ...(user ? cookie(user) : {}), ...headers } });++ const post = (p, user, form = {}, headers = {}) => fetch(t.base + p, {++ method: 'POST', redirect: 'manual', body: new URLSearchParams(form).toString(),++ headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...(user ? cookie(user) : {}), ...headers },++ });++++ const { robot } = await t.robot(alex, { name: 'Rover' });++++ await check('the owner sees the People card and adding @bob lists them, and the API agrees', async () => {++ const html = await (await get(`/panel/${robot.id}`, alex)).text();++ assert.ok(html.includes('class="setting-card people"') && html.includes('id="people-h"'));++ assert.ok(html.includes('<p class="setting-note">Operators drive and can press the e-stop; viewers watch.</p>'));++ assert.ok(html.includes('<li class="person"><span class="who"><b>You</b><small>Owner</small></span></li>'));++ assert.ok(html.includes(`<form class="people-add" method="post" action="/robots/${robot.id}/operators">`));++ assert.ok(html.includes('name="username" placeholder="@username" required maxlength="41"'));++++ const r = await post(`/robots/${robot.id}/operators`, alex, { username: '@bob', role: 'operator' });++ assert.strictEqual(r.status, 303, await r.text());++ assert.strictEqual(r.headers.get('location'), `/panel/${robot.id}`);++++ const after = await (await get(`/panel/${robot.id}`, alex)).text();++ assert.ok(after.includes('<b>Bob</b><small>@bob · Operator</small>'), after.match(/<ul class="people-list">[\s\S]*?<\/ul>/));++ assert.ok(after.includes(`action="/robots/${robot.id}/operators/${bob.subject}/remove"`));++ const api = await t.call('GET', `/api/v1/robots/${robot.id}/operators`, { user: alex });++ assert.ok(api.json.operators.some((o) => o.subject === bob.subject && o.role === 'operator'));++ });++++ await check('unknown name, bad role and the owner adding themself are 422', async () => {++ const unknown = await post(`/robots/${robot.id}/operators`, alex, { username: '@ghost', role: 'operator' });++ assert.strictEqual(unknown.status, 422);++ assert.match(await unknown.text(), /No OpenVibe account is called @ghost/);++ assert.strictEqual((await post(`/robots/${robot.id}/operators`, alex, { username: '@carol', role: 'admin' })).status, 422);++ assert.strictEqual((await post(`/robots/${robot.id}/operators`, alex, { username: '@alex', role: 'operator' })).status, 422);++ });++++ await check('a Network 404 that is not "no such person", or a 400, is an outage (503), never unknown_user', async () => {++ for (const status of [404, 400]) {++ t.network.failIdentity(status);++ try {++ const r = await post(`/robots/${robot.id}/operators`, alex, { username: '@carol', role: 'viewer' });++ const body = await r.text();++ assert.strictEqual(r.status, 503, `${status}: ${body}`);++ assert.match(body, /Network answered/);++ } finally { t.network.failIdentity(null); }++ }++ // The only 404 that means "no such account" is Network's own identity.subject_not_found (the 422 above).++ });++++ await check('a non-owner cannot add or remove people (and asks Network nothing)', async () => {++ t.network.failIdentity(500);++ try {++ assert.strictEqual((await post(`/robots/${robot.id}/operators`, bob, { username: '@carol', role: 'viewer' })).status, 403);++ assert.strictEqual((await post(`/robots/${robot.id}/operators/${bob.subject}/remove`, bob)).status, 403);++ } finally { t.network.failIdentity(null); }++ });++++ await check('the owner removes a person; the owner row stays', async () => {++ assert.strictEqual((await post(`/robots/${robot.id}/operators/${alex.subject}/remove`, alex)).status, 303);++ assert.ok((await t.domain.members.list(robot.id)).some((m) => m.subject === alex.subject && m.role === 'owner'));++ assert.strictEqual((await post(`/robots/${robot.id}/operators/${bob.subject}/remove`, alex)).status, 303);++ assert.ok(!(await t.domain.members.list(robot.id)).some((m) => m.subject === bob.subject));++ });++++ await check('removing an operator drops their live /control socket (form and API)', async () => {++ const { robot: r } = await t.robot(alex, { name: 'Watch' });++ assert.strictEqual((await post(`/robots/${r.id}/operators`, alex, { username: '@bob', role: 'operator' })).status, 303);++ const viaForm = await t.ws('/control', { headers: cookie(bob) });++ viaForm.send({ type: 'join', robot_id: r.id });++ assert.strictEqual((await viaForm.waitFor((m) => m.type === 'joined')).role, 'operator');++ assert.strictEqual((await post(`/robots/${r.id}/operators/${bob.subject}/remove`, alex)).status, 303);++ assert.strictEqual(await viaForm.waitForClose(), 4003, 'the removed operator\'s socket stayed open');++++ await t.domain.members.add(r.id, bob.subject, 'operator', alex.subject);++ const viaApi = await t.ws('/control', { headers: cookie(bob) });++ viaApi.send({ type: 'join', robot_id: r.id });++ assert.strictEqual((await viaApi.waitFor((m) => m.type === 'joined')).role, 'operator');++ const del = await t.call('DELETE', `/api/v1/robots/${r.id}/operators/${bob.subject}`, { user: alex });++ assert.strictEqual(del.status, 200, del.text);++ assert.strictEqual(await viaApi.waitForClose(), 4003, 'the API-removed operator\'s socket stayed open');++ });++++ await check('Network down: the panel still renders, with the subject id and "name unavailable"', async () => {++ await t.domain.members.add(robot.id, carol.subject, 'viewer', alex.subject);++ t.network.failIdentity(500);++ const r = await get(`/panel/${robot.id}`, alex);++ assert.strictEqual(r.status, 200);++ const html = await r.text();++ assert.ok(html.includes(`<b>${carol.subject}</b><small>name unavailable</small>`), html.match(/<ul class="people-list">[\s\S]*?<\/ul>/));++ t.network.failIdentity(null);++ const back = await (await get(`/panel/${robot.id}`, alex)).text();++ assert.ok(back.includes('<b>Carol</b><small>@carol · Viewer</small>'));++ });++++ await check('a queue robot: a signed-in non-member gets the turn strip; the owner does not', async () => {++ const { robot: q } = await t.robot(alex, { name: 'Queue', access_policy: 'queue' });++ const html = await (await get(`/panel/${q.id}`, bob)).text();++ assert.ok(html.includes('<div class="turn" data-turn hidden><span class="turn-state" data-turn-state></span><span class="turn-meta" data-turn-meta></span>'));++ assert.ok(html.includes(`<form method="post" action="/robots/${q.id}/queue/leave" data-turn-leave>`));++ const owner = await (await get(`/panel/${q.id}`, alex)).text();++ assert.ok(!owner.includes('data-turn'), 'the owner sees no leave strip');++ assert.ok(owner.includes('Anyone signed in can also take a turn'), 'the queue note is on the owner card');++++ // Bob joins on /control (the queue's own door) and becomes the active turn; Carol waits.++ const c = await t.ws('/control', { headers: cookie(bob) });++ c.send({ type: 'join', robot_id: q.id });++ const joined = await c.waitFor((m) => m.type === 'joined');++ assert.strictEqual(joined.role, 'queue');++ // A queue member's frame carries their own subject and no other: never the active driver's.++ assert.strictEqual(joined.state.queue.subject, bob.subject);++ assert.ok(!('turn_subject' in joined.state.queue), 'a queue member learned another subject');++ await t.domain.queue.join(q.id, carol.subject);++ const active = await t.domain.queue.state(q.id, bob.subject);++ assert.strictEqual(active.active, true);++ assert.ok(!('turn_subject' in active), 'the active driver\'s subject leaked into a queue view');++ const waiting = await t.domain.queue.state(q.id, carol.subject);++ assert.strictEqual(waiting.subject, carol.subject);++ assert.ok(!('turn_subject' in waiting), 'a waiting member learned the driver\'s subject');++++ let broadcasts = 0;++ const original = t.hub.broadcast;++ t.hub.broadcast = (id) => { if (id === q.id) broadcasts++; return original.call(t.hub, id); };++ try {++ const r = await post(`/robots/${q.id}/queue/leave`, bob);++ assert.strictEqual(r.status, 303);++ assert.strictEqual(r.headers.get('location'), `/panel/${q.id}`);++ } finally { t.hub.broadcast = original; }++ assert.strictEqual(broadcasts >= 1, true, 'the panels were not told');++++ assert.strictEqual(await t.db.maybe('SELECT * FROM robot_queue WHERE robot_id = $1 AND subject = $2', [q.id, bob.subject]), null);++ const left = await t.domain.queue.state(q.id, bob.subject);++ assert.strictEqual(left.active, false);++ assert.strictEqual(left.position, null);++ const promoted = await t.domain.queue.state(q.id, carol.subject);++ assert.strictEqual(promoted.active, true);++ assert.strictEqual(promoted.position, 0);++ c.close();++ });++++ await check('the panel client paints the strip from a state frame\'s queue', () => {++ const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'panel.js'), 'utf8');++ assert.match(js, /paintTurn\(m\.state && m\.state\.queue\)/);++ assert.match(js, /'Your turn'/);++ assert.match(js, /in line/);++ assert.match(js, /setInterval\(paintLeft, 1000\)/);++ });++++ await check('the identity cache is keyed by subject', async () => {++ const s = identityStub();++ const bob = s.add('bob');++ const resolved = await s.identity.byUsername('bob');++ assert.strictEqual(resolved.subject, bob.subject);++ assert.strictEqual(s.calls.resolve, 1);++ // The resolution is cached under the subject: names() for it needs no batch call.++ const cached = await s.identity.names([bob.subject]);++ assert.strictEqual(cached.get(bob.subject).username, 'bob');++ assert.strictEqual(s.calls.batch, 0, 'a resolved subject was looked up again');++ // A subject never resolved is a miss.++ const carol = s.add('carol');++ const fresh = await s.identity.names([carol.subject]);++ assert.strictEqual(fresh.get(carol.subject).username, 'carol');++ assert.strictEqual(s.calls.batch, 1);++ });++++ await check('the identity cache expires after its TTL', async () => {++ const s = identityStub();++ const bob = s.add('bob');++ await s.identity.names([bob.subject]);++ await s.identity.names([bob.subject]);++ assert.strictEqual(s.calls.batch, 1, 'a second lookup inside the TTL was not cached');++ s.setNow(s.now() + 5 * 60 * 1000 + 1);++ await s.identity.names([bob.subject]);++ assert.strictEqual(s.calls.batch, 2, 'an expired entry was still served');++ });++++ await check('the identity cache holds at most 2000 subjects', async () => {++ const s = identityStub();++ const subjects = [];++ for (let i = 0; i < 2001; i++) subjects.push(s.add(`u${i}`).subject);++ await s.identity.names(subjects);++ const filled = s.calls.batch;++ await s.identity.names([subjects[0]]); // the oldest was evicted at the bound++ assert.strictEqual(s.calls.batch, filled + 1, 'the cache grew past 2000 entries');++ await s.identity.names([subjects[2000]]); // the newest is still held++ assert.strictEqual(s.calls.batch, filled + 1);++ });++++ await t.close();++ done();++})();
@@ -16,6 +16,7 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato
@@ -16,6 +16,7 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato
- The signed-in pages (`server/web/routes.js`): `/robots` to list and add robots, `/pair/:id` for a pairing code and the installer command, and `/panel/:id`, the panel rendered from the profile. A `sim.rover` robot is driven by an in-process simulator (`server/sim`), so the panel works before any hardware exists. It gets no jobs, and it is not counted as a device or reported online in the outbox. The panel (`public/panel.js`, no build step) drives with a touch joystick, the keyboard or a gamepad, shows a latency meter and one camera tile per camera the profile lists; the camera stays a placeholder until OpenRe.Stream can play a WHIP-published stream back to a browser.
- The signed-in pages (`server/web/routes.js`): `/robots` to list and add robots, `/pair/:id` for a pairing code and the installer command, and `/panel/:id`, the panel rendered from the profile. A `sim.rover` robot is driven by an in-process simulator (`server/sim`), so the panel works before any hardware exists. It gets no jobs, and it is not counted as a device or reported online in the outbox. The panel (`public/panel.js`, no build step) drives with a touch joystick, the keyboard or a gamepad, shows a latency meter and one camera tile per camera the profile lists; the camera stays a placeholder until OpenRe.Stream can play a WHIP-published stream back to a browser.
- Server-side ONVIF cameras (`camera.onvif`, `mapping.driver: onvif`): an in-process connector beside the simulator (`server/onvif`) attaches a camera configured with `BOT_ONVIF_CAMERAS`, writes a real `kind: server` device row, translates a `ptz` command into ONVIF `ContinuousMove`/`Stop` (with the profile's deadline as a deadman) and carries the camera's `GetStatus` readout in telemetry. Its credentials are secret references only (`username_ref`/`password_ref`).
- Server-side ONVIF cameras (`camera.onvif`, `mapping.driver: onvif`): an in-process connector beside the simulator (`server/onvif`) attaches a camera configured with `BOT_ONVIF_CAMERAS`, writes a real `kind: server` device row, translates a `ptz` command into ONVIF `ContinuousMove`/`Stop` (with the profile's deadline as a deadman) and carries the camera's `GetStatus` readout in telemetry. Its credentials are secret references only (`username_ref`/`password_ref`).
- The command audit (`command_audit`, kept 30 days) and the `bot.*` outbox events.
- The command audit (`command_audit`, kept 30 days) and the `bot.*` outbox events.
+- Operators and viewers from the panel: the owner adds a person to a robot by `@username` (OpenVibe.Network resolves the name to a subject) and removes them again, each listed on the panel's People card. On an open-queue robot a signed-in visitor sees their turn — or their place in line — and can leave the queue, which promotes the next person at once (`POST /robots/:id/operators`, `POST /robots/:id/operators/:subject/remove`, `POST /robots/:id/queue/leave`, all plain forms that work without JavaScript).
- The robot's streaming and recording toggles, off by default: `media` records live sessions to OpenVibe.Media and `live` shows them on the owner's OpenVibe.Live channel. They live only on the robot's OpenRe stream (`recording_mode`, `mirror_to_live`); Bot stores no copy and the owner, not an operator, flips them (`GET`/`POST /api/v1/robots/:id/streaming`).
- The robot's streaming and recording toggles, off by default: `media` records live sessions to OpenVibe.Media and `live` shows them on the owner's OpenVibe.Live channel. They live only on the robot's OpenRe stream (`recording_mode`, `mirror_to_live`); Bot stores no copy and the owner, not an operator, flips them (`GET`/`POST /api/v1/robots/:id/streaming`).
- Dispatching `platform.job@1` jobs to a paired Node over `/device` (`server/jobs/index.js` over `server/jobs/dispatch.js`; the internal `bot.job.dispatch` API is `POST /api/v1/jobs`, `POST /jobs/:id/cancel`, `GET /jobs/:id`) and metering them per wall-clock second (`run_jobs`, `run_usage_outbox`; docs/protocol.md §1.2).
- Dispatching `platform.job@1` jobs to a paired Node over `/device` (`server/jobs/index.js` over `server/jobs/dispatch.js`; the internal `bot.job.dispatch` API is `POST /api/v1/jobs`, `POST /jobs/:id/cancel`, `GET /jobs/:id`) and metering them per wall-clock second (`run_jobs`, `run_usage_outbox`; docs/protocol.md §1.2).
@@ -36,7 +37,7 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato
@@ -36,7 +37,7 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato
- PostgreSQL 18: robots, devices, profile rows, pairing codes, the command audit and the event outbox; `migrations/` applies on boot.
- PostgreSQL 18: robots, devices, profile rows, pairing codes, the command audit and the event outbox; `migrations/` applies on boot.
- Valkey (`VALKEY_URL`, optional): the shared per-actor limit counters; unset, limits are counted per process.
- Valkey (`VALKEY_URL`, optional): the shared per-actor limit counters; unset, limits are counted per process.
-- OpenVibe.Network: user and service tokens, node tokens, and pairing when the authority is `network`.
+- OpenVibe.Network: user and service tokens, node tokens, pairing when the authority is `network`, and identity resolution (a `@username` to a subject, and subjects to names, for the panel's People card).
- OpenRe.Stream: the WHIP ingest base (`BOT_WHIP_BASE`) devices publish to.
- OpenRe.Stream: the WHIP ingest base (`BOT_WHIP_BASE`) devices publish to.
- OpenVibe.Billing: job usage readings go to `billing.usage.record` (`BOT_BILLING_URL`, `BOT_BILLING_TOKEN`); unset, they wait in `run_usage_outbox`.
- OpenVibe.Billing: job usage readings go to `billing.usage.record` (`BOT_BILLING_URL`, `BOT_BILLING_TOKEN`); unset, they wait in `run_usage_outbox`.
- `openvibe-contracts` v0.96.0, `openvibe-sdk` v0.26.0 and `openvibe-shared` v2.5.0 (package.json).
- `openvibe-contracts` v0.96.0, `openvibe-sdk` v0.26.0 and `openvibe-shared` v2.5.0 (package.json).
@@ -39,6 +39,7 @@
@@ -39,6 +39,7 @@
"public robot-profile catalogue (GET /profiles, /profiles/:id); robots are private to their owner, with public control only as a timed turn queue",
"public robot-profile catalogue (GET /profiles, /profiles/:id); robots are private to their owner, with public control only as a timed turn queue",
"per-actor limits on capability-boundary writes (openvibe-sdk/limits; shared through Valkey when VALKEY_URL is set)",
"per-actor limits on capability-boundary writes (openvibe-sdk/limits; shared through Valkey when VALKEY_URL is set)",
"signed-in pages: /robots (list and add), /pair/:id (the code and the one-paste installer command) and /panel/:id, rendered from the robot's profile and driven over /control; a sim.rover robot is driven by an in-process simulated device",
"signed-in pages: /robots (list and add), /pair/:id (the code and the one-paste installer command) and /panel/:id, rendered from the robot's profile and driven over /control; a sim.rover robot is driven by an in-process simulated device",
+ "the panel's People card: the owner adds an operator or viewer by @username (OpenVibe.Network resolves the name to a subject) and removes them again, shown with Network's names; a signed-in visitor on an open-queue robot sees their turn or their place in line and can leave the queue, promoting the next person (POST /robots/:id/operators and …/operators/:subject/remove, POST /robots/:id/queue/leave)",
"panel input: a touch joystick, the keyboard and a gamepad (Gamepad API), each held control re-sent while held and zeroed at once on release; a latency meter (round-trip median and telemetry age); one camera tile per camera the profile lists (a placeholder until OpenRe can play a WHIP stream back); a live online indicator on /pair/:id read from /control",
"panel input: a touch joystick, the keyboard and a gamepad (Gamepad API), each held control re-sent while held and zeroed at once on release; a latency meter (round-trip median and telemetry age); one camera tile per camera the profile lists (a placeholder until OpenRe can play a WHIP stream back); a live online indicator on /pair/:id read from /control",
"platform.job@1 jobs dispatched to a paired Node over /device (platform.job-frame@1: resent unacked on reconnect, only to an advertised runtime class) and metered per second into run_usage_outbox for billing.usage.record",
"platform.job@1 jobs dispatched to a paired Node over /device (platform.job-frame@1: resent unacked on reconnect, only to an advertised runtime class) and metered per second into run_usage_outbox for billing.usage.record",
"embed opt-in: robots.embed_public (migration 0005), set only by the owner with POST /robots/:id/embed, web-only (not in /api/v1); BOT_EMBED_ORIGINS is the validated frame-ancestors allow-list for the embeddable panel (default OpenVibe.Live)",
"embed opt-in: robots.embed_public (migration 0005), set only by the owner with POST /robots/:id/embed, web-only (not in /api/v1); BOT_EMBED_ORIGINS is the validated frame-ancestors allow-list for the embeddable panel (default OpenVibe.Live)",
@@ -330,8 +330,9 @@ needs an explicit owner or operator role — a no-role caller is refused `bot.no
@@ -330,8 +330,9 @@ needs an explicit owner or operator role — a no-role caller is refused `bot.no
| `error` | `code, detail` | a frame or join the server refused |
| `error` | `code, detail` | a frame or join the server refused |
`robot_state.queue` (only for a `queue` robot) is
`robot_state.queue` (only for a `queue` robot) is
-`{ robot_id, subject, active, turn_ends_at, turn_subject, position, budget, used }` — a waiting
-person's `position` is 1-based (0 while they hold the turn).
+`{ robot_id, subject, active, turn_ends_at, position, budget, used }` — `subject` is the reader's own,
+never another person's, so a waiting person's `position` is 1-based (0 while they hold the turn) and no
+member ever learns the active driver's subject.
### Examples
### Examples
@@ -470,7 +471,11 @@ node token, and a service token is judged on `bot.job.dispatch` alone.
@@ -470,7 +471,11 @@ node token, and a service token is judged on `bot.job.dispatch` alone.
Not in `/api/v1`: `GET /api/health`, `GET /api/ready`, `GET /metrics`, `GET /release.json`, the
Not in `/api/v1`: `GET /api/health`, `GET /api/ready`, `GET /metrics`, `GET /release.json`, the
`/auth/*` SSO routes, `GET /` (a one-line text health placeholder), `GET /install` (a 302 to the installer),
`/auth/*` SSO routes, `GET /` (a one-line text health placeholder), `GET /install` (a 302 to the installer),
-and the signed-in pages `GET|POST /robots`, `GET /pair/:id`, `GET /panel/:id` and `GET /panel/panel.{js,css}`
+and the signed-in pages `GET|POST /robots`, `GET /pair/:id`, `GET /panel/:id`, `GET /panel/panel.{js,css}`,
+the panel's owner-only forms `POST /robots/:id/operators` (body `username`, `role` `operator|viewer`; a leading
+`@` is stripped; an unknown name is `422 bot.unknown_user` with `No OpenVibe account is called @<name>`) and
+`POST /robots/:id/operators/:subject/remove` (the owner row cannot be removed), and `POST /robots/:id/queue/leave`
+(any signed-in person; the next in line is promoted)
(`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`).
@@ -245,3 +245,12 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf
@@ -245,3 +245,12 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf
.kit-parts summary::before { content: ''; width: 0.45rem; height: 0.45rem; margin: 0 0.4rem 0 0.15rem; border-right: 2px solid var(--muted); border-bottom: 2px solid var(--muted); transform: rotate(-45deg); transition: transform 0.12s; }
.kit-parts summary::before { content: ''; width: 0.45rem; height: 0.45rem; margin: 0 0.4rem 0 0.15rem; border-right: 2px solid var(--muted); border-bottom: 2px solid var(--muted); transform: rotate(-45deg); transition: transform 0.12s; }
.kit-parts[open] summary::before { transform: rotate(45deg); }
.kit-parts[open] summary::before { transform: rotate(45deg); }
@media (prefers-reduced-motion: reduce) { .kit-parts summary::before { transition: none; } }
@media (prefers-reduced-motion: reduce) { .kit-parts summary::before { transition: none; } }
+
+/* The owner's People card and the turn-queue strip. */
+.people-list { list-style: none; margin: 0 0 0.75rem; padding: 0; display: grid; gap: 0.4rem; }
+.person { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; padding: 0.5rem 0.65rem; border: 1px solid var(--line); border-radius: 10px; background: var(--raised); }
+.person .who { display: grid; min-width: 0; } .person .who b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .person .who small { color: var(--muted); font-size: 0.82rem; }
+.people-add { display: flex; flex-wrap: wrap; gap: 0.5rem; } .people-add input { flex: 1 1 10rem; min-width: 0; padding: 0 0.75rem; border: 1px solid var(--line); background: var(--bg); color: var(--text); } .people-add select { padding: 0 0.5rem; border: 1px solid var(--line); background: var(--bg); color: var(--text); }
+.turn { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem 0.9rem; margin-top: 0.6rem; padding: 0.5rem 0.75rem; border: 1px solid var(--accent); border-radius: 10px; background: color-mix(in srgb, var(--accent) 12%, transparent); }
+.turn-state { font-weight: 700; } .turn-meta { color: var(--muted); font-variant-numeric: tabular-nums; } .turn form { margin-left: auto; }
+.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
@@ -477,6 +477,33 @@
@@ -477,6 +477,33 @@
}
}
}
}
+ // ── The turn queue strip (a `queue` robot's signed-in non-member) ─────────────────────────────────
+ const turnEl = $('[data-turn]');
+ const turnStateEl = $('[data-turn-state]');
+ const turnMetaEl = $('[data-turn-meta]');
+ let turnTimer = null;
+ const stopTurnTimer = () => { if (turnTimer) { clearInterval(turnTimer); turnTimer = null; } };
+ /** Paint the queue view from a state frame's `queue`: the active turn's countdown, a place in line, or hidden. */
+ function paintTurn(queue) {
+ stopTurnTimer();
+ if (!turnEl) return;
+ if (!queue || (!queue.active && !(queue.position > 0))) { turnEl.hidden = true; return; }
+ turnEl.hidden = false;
+ if (queue.active) {
+ const ends = queue.turn_ends_at ? new Date(queue.turn_ends_at).getTime() : null;
+ const paintLeft = () => {
+ const left = ends != null ? Math.max(0, Math.ceil((ends - Date.now()) / 1000)) : null;
+ turnStateEl.textContent = 'Your turn';
+ turnMetaEl.textContent = `${left == null ? '—' : `${left}s`} left · ${queue.used} of ${queue.budget} commands`;
+ };
+ paintLeft();
+ if (ends != null) turnTimer = setInterval(paintLeft, 1000);
+ } else {
+ turnStateEl.textContent = `You're #${queue.position} in line`;
+ turnMetaEl.textContent = '';
+ }
+ }
+
function onFrame(m) {
function onFrame(m) {
if (m.type === 'joined') {
if (m.type === 'joined') {
joined = true; retry = 0;
joined = true; retry = 0;
@@ -484,8 +511,10 @@
@@ -484,8 +511,10 @@
notice('');
notice('');
enableControls();
enableControls();
paint(m.state);
paint(m.state);
+ paintTurn(m.state && m.state.queue);
} else if (m.type === 'robot_state') {
} else if (m.type === 'robot_state') {
paint(m.state);
paint(m.state);
+ paintTurn(m.state && m.state.queue);
} else if (m.type === 'command_result') {
} else if (m.type === 'command_result') {
roundTrip(m.id, m.result);
roundTrip(m.id, m.result);
if (m.result === 'refused' && m.code !== 'bot.cooldown') notice(m.reason || m.code);
if (m.result === 'refused' && m.code !== 'bot.cooldown') notice(m.reason || m.code);
@@ -501,6 +530,7 @@
@@ -501,6 +530,7 @@
ws.onmessage = (e) => { let m; try { m = JSON.parse(e.data); } catch { return; } onFrame(m); };
ws.onmessage = (e) => { let m; try { m = JSON.parse(e.data); } catch { return; } onFrame(m); };
ws.onclose = (e) => {
ws.onclose = (e) => {
releaseAll();
releaseAll();
+ stopTurnTimer();
joined = false;
joined = false;
enableControls();
enableControls();
setOnline('disconnected', 'Reconnecting…');
setOnline('disconnected', 'Reconnecting…');
@@ -22,7 +22,7 @@ const { http } = require('openvibe-contracts');
@@ -22,7 +22,7 @@ const { http } = require('openvibe-contracts');
const { isLoopbackDirect } = require('openvibe-shared/metrics');
const { isLoopbackDirect } = require('openvibe-shared/metrics');
const { loadConfig } = require('./config');
const { loadConfig } = require('./config');
const { openDb } = require('./db');
const { openDb } = require('./db');
-const { createKeyProvider, createUserAuth, createNodePrincipals } = require('./network');
+const { createKeyProvider, createUserAuth, createNodePrincipals, createIdentity } = require('./network');
const { createOpenRe } = require('./openre/client');
const { createOpenRe } = require('./openre/client');
const { createBotOutbox } = require('./events/outbox');
const { createBotOutbox } = require('./events/outbox');
const { createUsageRelay } = require('./jobs/metering');
const { createUsageRelay } = require('./jobs/metering');
@@ -55,6 +55,7 @@ function createApp(opts = {}) {
@@ -55,6 +55,7 @@ function createApp(opts = {}) {
const usage = opts.usage || createUsageRelay({ db, config, fetchImpl: opts.billingFetch || fetchImpl, now, log });
const usage = opts.usage || createUsageRelay({ db, config, fetchImpl: opts.billingFetch || fetchImpl, now, log });
const hub = opts.hub || createRealtime({ config, keys, userAuth, log, now });
const hub = opts.hub || createRealtime({ config, keys, userAuth, log, now });
const nodes = opts.nodes || createNodePrincipals(config, { fetchImpl });
const nodes = opts.nodes || createNodePrincipals(config, { fetchImpl });
+ const identity = opts.identity || createIdentity(config, { fetchImpl });
const openre = opts.openre !== undefined ? opts.openre : createOpenRe(config, { fetchImpl });
const openre = opts.openre !== undefined ? opts.openre : createOpenRe(config, { fetchImpl });
const domain = createDomain({ db, config, outbox, link: hub, nodes, openre, now, log });
const domain = createDomain({ db, config, outbox, link: hub, nodes, openre, now, log });
hub.bindDomain(domain);
hub.bindDomain(domain);
@@ -97,7 +98,7 @@ function createApp(opts = {}) {
@@ -97,7 +98,7 @@ function createApp(opts = {}) {
app.use('/api/v1', express.json({ limit: '64kb' }), (req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); }, apiAuth.middleware, v1Router({ domain, apiAuth, limits, hub, jobs, config }));
app.use('/api/v1', express.json({ limit: '64kb' }), (req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); }, apiAuth.middleware, v1Router({ domain, apiAuth, limits, hub, jobs, config }));
app.use('/auth', createSessionRoutes(config, userAuth, { fetchImpl }));
app.use('/auth', createSessionRoutes(config, userAuth, { fetchImpl }));
app.use(viewerMiddleware(userAuth));
app.use(viewerMiddleware(userAuth));
- app.use(createWebRoutes(config, { domain, sim, onvif, limits, log }));
+ app.use(createWebRoutes(config, { domain, sim, onvif, limits, identity, hub, log }));
app.use((req, res) => {
app.use((req, res) => {
if (req.path.startsWith('/api/') || req.path.startsWith('/internal/')) return http.sendProblem(res, 404, 'not_found', { ctx: req.ov });
if (req.path.startsWith('/api/') || req.path.startsWith('/internal/')) return http.sendProblem(res, 404, 'not_found', { ctx: req.ov });
@@ -101,13 +101,26 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -101,13 +101,26 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
async function addOperator(robotId, subject, role, addedBy) {
async function addOperator(robotId, subject, role, addedBy) {
if (!['owner', 'operator', 'viewer'].includes(role)) fail(422, 'bot.invalid_role', 'role must be owner, operator or viewer');
if (!['owner', 'operator', 'viewer'].includes(role)) fail(422, 'bot.invalid_role', 'role must be owner, operator or viewer');
if (role === 'owner') fail(422, 'bot.invalid_role', 'ownership is not transferred by adding an operator');
if (role === 'owner') fail(422, 'bot.invalid_role', 'ownership is not transferred by adding an operator');
+ const before = await roleOf(robotId, subject);
await db.query(
await db.query(
`INSERT INTO robot_operators (robot_id, subject, role, added_by, created_at) VALUES ($1, $2, $3, $4, $5)
`INSERT INTO robot_operators (robot_id, subject, role, added_by, created_at) VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (robot_id, subject) DO UPDATE SET role = EXCLUDED.role, added_by = EXCLUDED.added_by`,
ON CONFLICT (robot_id, subject) DO UPDATE SET role = EXCLUDED.role, added_by = EXCLUDED.added_by`,
[robotId, subject, role, addedBy || null, iso(now())]);
[robotId, subject, role, addedBy || null, iso(now())]);
+ if (before !== role) membershipChanged(robotId, subject, 'your role on this robot changed');
}
}
async function removeOperator(robotId, subject) {
async function removeOperator(robotId, subject) {
+ const before = await roleOf(robotId, subject);
await db.query(`DELETE FROM robot_operators WHERE robot_id = $1 AND subject = $2 AND role <> 'owner'`, [robotId, subject]);
await db.query(`DELETE FROM robot_operators WHERE robot_id = $1 AND subject = $2 AND role <> 'owner'`, [robotId, subject]);
+ if (before && before !== 'owner') membershipChanged(robotId, subject, 'you were removed from this robot');
+ }
+ /**
+ * A membership change: the person's live /control sockets on the robot are dropped (so their join
+ * re-derives the role) and every panel of the robot is re-broadcast. The one place both the web form
+ * routes and the v1 API reach, since both change membership through addOperator/removeOperator.
+ */
+ function membershipChanged(robotId, subject, reason) {
+ if (link && link.dropSubject) link.dropSubject(robotId, subject, reason);
+ if (link && link.broadcast) link.broadcast(robotId);
}
}
// ── Robots ────────────────────────────────────────────────────────────────────────────────────
// ── Robots ────────────────────────────────────────────────────────────────────────────────────
@@ -600,6 +613,17 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -600,6 +613,17 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
await currentTurn(robotId);
await currentTurn(robotId);
return queueState(robotId, subject);
return queueState(robotId, subject);
}
}
+ /**
+ * Leave the queue: remove the person's row (waiting or the active turn) and, when their turn ends,
+ * promote the oldest waiting one. Returns the leaver's view of the queue (null/mostly empty when gone).
+ */
+ async function leaveQueue(robotId, subject) {
+ const robot = await getRobot(robotId);
+ if (!robot) fail(404, 'bot.robot_not_found', 'no such robot');
+ await db.query('DELETE FROM robot_queue WHERE robot_id = $1 AND subject = $2', [robotId, subject]);
+ await currentTurn(robotId);
+ return queueState(robotId, subject);
+ }
async function queueState(robotId, subject) {
async function queueState(robotId, subject) {
const robot = await getRobot(robotId);
const robot = await getRobot(robotId);
if (!robot) return null;
if (!robot) return null;
@@ -613,7 +637,6 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -613,7 +637,6 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
return {
return {
robot_id: robotId, subject: subject || null, active: !!(active && active.subject === subject),
robot_id: robotId, subject: subject || null, active: !!(active && active.subject === subject),
turn_ends_at: active ? iso(new Date(active.turn_ends_at).getTime()) : null,
turn_ends_at: active ? iso(new Date(active.turn_ends_at).getTime()) : null,
- turn_subject: active ? active.subject : null,
position: active && active.subject === subject ? 0 : position,
position: active && active.subject === subject ? 0 : position,
budget: turnBudget(robot),
budget: turnBudget(robot),
used: active && active.subject === subject ? active.commands_used : 0,
used: active && active.subject === subject ? active.commands_used : 0,
@@ -865,7 +888,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -865,7 +888,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
devices: { byCredential, bindNode, issuePublishKey, revokeVideo, updateDeclared, revokeNode, ensureServerDevice, get: getDevice, listForRobot: listDevicesForRobot, rotate: rotateDevice, revoke: revokeDevice, touchSeen, setOnline },
devices: { byCredential, bindNode, issuePublishKey, revokeVideo, updateDeclared, revokeNode, ensureServerDevice, get: getDevice, listForRobot: listDevicesForRobot, rotate: rotateDevice, revoke: revokeDevice, touchSeen, setOnline },
streaming: { get: streaming, set: setStreaming },
streaming: { get: streaming, set: setStreaming },
estop: { set: setEstop, clear: clearEstop },
estop: { set: setEstop, clear: clearEstop },
- queue: { join: joinQueue, state: queueState, currentTurn, consume: consumeTurn, sweep: sweepQueues },
+ queue: { join: joinQueue, leave: leaveQueue, state: queueState, currentTurn, consume: consumeTurn, sweep: sweepQueues },
audit: { record: auditCommand, list: listAudit, listPage: listAuditPage, prune: pruneAudit },
audit: { record: auditCommand, list: listAudit, listPage: listAuditPage, prune: pruneAudit },
control: { prepare, allowedFor, effectiveLimits, deviceLimits, buildValue, DEFAULT_ALLOW, KINDS },
control: { prepare, allowedFor, effectiveLimits, deviceLimits, buildValue, DEFAULT_ALLOW, KINDS },
};
};
@@ -9,6 +9,8 @@
@@ -9,6 +9,8 @@
* nodes Network's node principals (machines paired for Bot): POST /internal/node-pairings (a pairing
* nodes Network's node principals (machines paired for Bot): POST /internal/node-pairings (a pairing
* code, BOT_PAIRING_AUTHORITY=network), GET /internal/node-principals/:id and POST …/:id/revoke with
* code, BOT_PAIRING_AUTHORITY=network), GET /internal/node-principals/:id and POST …/:id/revoke with
* Bot's own svc:bot token (audience openvibe.network, network.node.manage).
* Bot's own svc:bot token (audience openvibe.network, network.node.manage).
+ * identity Network's people (identity.subject.resolve): resolve a username to a subject and look up the
+ * names of subjects the robot's owner added.
*/
*/
const crypto = require('crypto');
const crypto = require('crypto');
const jwt = require('jsonwebtoken');
const jwt = require('jsonwebtoken');
@@ -109,4 +111,74 @@ function createNodePrincipals(config, { fetchImpl = globalThis.fetch } = {}) {
@@ -109,4 +111,74 @@ function createNodePrincipals(config, { fetchImpl = globalThis.fetch } = {}) {
};
};
}
}
-module.exports = { createKeyProvider, createUserAuth, createNodePrincipals };
+/**
+ * Network's identity resolution for people (usernames ↔ subjects), with Bot's own svc:bot token
+ * (audience openvibe.network, identity.subject.resolve — Network grants Bot this).
+ * byUsername(name) → GET /internal/identity/resolve?username=<name> → { subject, username, display_name },
+ * or null when Network knows no such person (a 404 whose problem code is
+ * identity.subject_not_found).
+ * names(subjectIds) → POST /internal/identity/resolve-batch { subject_ids } → Map(subject → { username,
+ * display_name }), skipping the ids Network does not know. Answers are cached in memory
+ * for five minutes, at most 2000 subjects.
+ * Any other failure throws 503 bot.network_unavailable.
+ */
+function createIdentity(config, { fetchImpl = globalThis.fetch, now = () => Date.now() } = {}) {
+ const tokens = serviceAuth.createTokenClient({
+ tokenUrl: `${config.network.internalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
+ audience: 'openvibe.network', scope: 'identity.subject.resolve', fetchImpl,
+ });
+ const TTL_MS = 5 * 60 * 1000;
+ const MAX_ENTRIES = 2000;
+ const cache = new Map(); // subject → { username, display_name, at }
+ async function call(method, path, body) {
+ let res;
+ try {
+ res = await fetchImpl(`${config.network.internalUrl}${path}`, {
+ method, headers: { ...(await tokens.authHeaders()), Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}) },
+ ...(body ? { body: JSON.stringify(body) } : {}), signal: AbortSignal.timeout(8000),
+ });
+ } catch (e) {
+ throw new BotError(503, 'bot.network_unavailable', `Network did not answer to resolve a person: ${e.message}`);
+ }
+ if (res.status === 401) tokens.invalidate();
+ const out = await res.json().catch(() => null);
+ // Only Network's own "no such person" is null. A 404 from a Network that lacks the route (or any
+ // other 404) and a 400 identity.bad_request are an outage, never "no such account".
+ if (method === 'GET' && res.status === 404 && out && out.code === 'identity.subject_not_found') return null;
+ if (!res.ok || !out) throw new BotError(503, 'bot.network_unavailable', `Network answered ${res.status} for identity`);
+ return out;
+ }
+ function remember(subject, p) {
+ if (!subject || !p || typeof p.username !== 'string' || !p.username) return;
+ if (cache.size >= MAX_ENTRIES) cache.delete(cache.keys().next().value);
+ cache.set(subject, { username: p.username, display_name: typeof p.display_name === 'string' && p.display_name ? p.display_name : p.username, at: now() });
+ }
+ async function byUsername(name) {
+ const out = await call('GET', `/internal/identity/resolve?username=${encodeURIComponent(name)}`);
+ if (!out || typeof out.subject !== 'string') return null;
+ remember(out.subject, out);
+ return { subject: out.subject, username: out.username, display_name: out.display_name || out.username };
+ }
+ async function names(subjectIds) {
+ const ids = [...new Set((subjectIds || []).filter((s) => typeof s === 'string' && s))];
+ const found = new Map();
+ const missing = [];
+ for (const id of ids) {
+ const hit = cache.get(id);
+ if (hit && now() - hit.at < TTL_MS) found.set(id, { username: hit.username, display_name: hit.display_name });
+ else missing.push(id);
+ }
+ for (let i = 0; i < missing.length; i += 500) {
+ const out = await call('POST', '/internal/identity/resolve-batch', { subject_ids: missing.slice(i, i + 500) });
+ for (const [subject, p] of Object.entries((out && out.results) || {})) {
+ if (!p || typeof p.username !== 'string') continue;
+ remember(subject, p);
+ found.set(subject, { username: p.username, display_name: p.display_name || p.username });
+ }
+ }
+ return found;
+ }
+ return { byUsername, names };
+}
+
+module.exports = { createKeyProvider, createUserAuth, createNodePrincipals, createIdentity };
@@ -593,6 +593,22 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -593,6 +593,22 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
for (const c of [...set]) if (c.watcher) { unsubscribe(c); try { c.ws.close(4003, reason); } catch { /* gone */ } }
for (const c of [...set]) if (c.watcher) { unsubscribe(c); try { c.ws.close(4003, reason); } catch { /* gone */ } }
}
}
+ /**
+ * A signed-in person's access to the robot changed (removed, or their role moved): close every
+ * non-watcher socket of theirs on this robot with 4003, so the client reconnects and its join
+ * re-derives the role. A watcher is anonymous and is never that subject.
+ */
+ function dropSubject(robotId, subject, reason = 'your access to this robot changed') {
+ const set = subsByRobot.get(robotId);
+ if (!set || !subject) return;
+ for (const c of [...set]) {
+ if (c.watcher || c.subject !== subject) continue;
+ unsubscribe(c);
+ c.robotId = null;
+ try { c.ws.close(4003, reason); } catch { /* gone */ }
+ }
+ }
+
// ── State and helpers ─────────────────────────────────────────────────────────────────────────
// ── State and helpers ─────────────────────────────────────────────────────────────────────────
function deviceForRobot(robotId) {
function deviceForRobot(robotId) {
let sim = null;
let sim = null;
@@ -705,7 +721,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -705,7 +721,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
bindDomain(d) { domain = d; },
bindDomain(d) { domain = d; },
bindJobs(j) { jobs = j; },
bindJobs(j) { jobs = j; },
handleUpgrade, attachSim, detachSim,
handleUpgrade, attachSim, detachSim,
- isOnline, deviceState, sendToDevice, sendToRobotDevices, broadcast, closeDevice, robotState, refreshConfig, pushEstop, closeWatchers, operatorCommand,
+ isOnline, deviceState, sendToDevice, sendToRobotDevices, broadcast, closeDevice, robotState, refreshConfig, pushEstop, closeWatchers, dropSubject, operatorCommand,
// Real devices only: a simulator is not a machine anyone runs.
// Real devices only: a simulator is not a machine anyone runs.
onlineCount() { let n = 0; for (const c of deviceConns.values()) if (c.online && !c.sim) n++; return n; },
onlineCount() { let n = 0; for (const c of deviceConns.values()) if (c.online && !c.sim) n++; return n; },
devices() { return [...deviceConns.values()].filter((c) => !c.sim).map((c) => ({ device_id: c.device.id, online: c.online })); },
devices() { return [...deviceConns.values()].filter((c) => !c.sim).map((c) => ({ device_id: c.device.id, online: c.online })); },
@@ -7,8 +7,9 @@
@@ -7,8 +7,9 @@
* code here. Every value is HTML-escaped; nothing is inline (the CSP is `default-src 'self'`), so the client
* code here. Every value is HTML-escaped; nothing is inline (the CSP is `default-src 'self'`), so the client
* (public/panel.js) reads what it needs from data-* attributes, and the icons are inline SVG markup.
* (public/panel.js) reads what it needs from data-* attributes, and the icons are inline SVG markup.
*
*
- * renderPanel({ robot, profile, role, allowed_commands, holdResendMs, mode, signedIn })
- * mode 'embed': the framed panel (no topbar, no owner form, links open a new tab)
+ * renderPanel({ robot, profile, role, allowed_commands, holdResendMs, mode, signedIn, streaming, people })
+ * mode 'embed': the framed panel (no topbar, no owner form, links open a new tab);
+ * `people` (owner page only) the non-owner members for the People card
* renderEmbedRefused({ robotId }) the framed answer when the visitor may not see the robot
* renderEmbedRefused({ robotId }) the framed answer when the visitor may not see the robot
* renderWidget(widget, { profile, allowed_commands })
* renderWidget(widget, { profile, allowed_commands })
* renderRobotsPage({ robots, profiles, error, values })
* renderRobotsPage({ robots, profiles, error, values })
@@ -254,7 +255,42 @@ ${toggle('live', s.live && s.live.on, 'Show on my OpenVibe.Live channel', 'Lets
@@ -254,7 +255,42 @@ ${toggle('live', s.live && s.live.on, 'Show on my OpenVibe.Live channel', 'Lets
/** A link out of a frame: always a new tab, never with a handle back to the framing page. */
/** A link out of a frame: always a new tab, never with a handle back to the framing page. */
const outLink = (href, text, cls = 'button') => `<a class="${cls}" href="${esc(href)}" target="_blank" rel="noopener">${esc(text)}</a>`;
const outLink = (href, text, cls = 'button') => `<a class="${cls}" href="${esc(href)}" target="_blank" rel="noopener">${esc(text)}</a>`;
-function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs = 150, mode = 'page', signedIn = true, streaming = null }) {
+/**
+ * The owner's People card: who may drive (operators) and who may watch (viewers), each with Network's name
+ * (or the subject id when Network was not reachable while the page was drawn), and the @username add form.
+ * Rendered only for the owner's own page.
+ */
+function peopleCard(robot, people) {
+ const roleName = (r) => (r === 'viewer' ? 'Viewer' : 'Operator');
+ const row = (p) => {
+ const remove = `<form method="post" action="/robots/${esc(robot.id)}/operators/${esc(encodeURIComponent(p.subject))}/remove"><button type="submit" class="quiet">Remove</button></form>`;
+ const who = p.username
+ ? `<b>${esc(p.display_name)}</b><small>@${esc(p.username)} · ${roleName(p.role)}</small>`
+ : `<b>${esc(p.subject)}</b><small>name unavailable</small>`;
+ return `<li class="person"><span class="who">${who}</span>${remove}</li>`;
+ };
+ const queue = robot.access_policy === 'queue' ? ' Anyone signed in can also take a turn: this robot uses the open queue.' : '';
+ return `<section class="setting-card people" aria-labelledby="people-h"><h2 id="people-h">People</h2>
+<p class="setting-note">Operators drive and can press the e-stop; viewers watch.${queue}</p>
+<ul class="people-list">
+<li class="person"><span class="who"><b>You</b><small>Owner</small></span></li>
+${(people || []).map(row).join('\n')}
+</ul>
+<form class="people-add" method="post" action="/robots/${esc(robot.id)}/operators"><label class="sr-only" for="people-name">Username</label><input id="people-name" name="username" placeholder="@username" required maxlength="41" autocomplete="off"><select name="role" aria-label="Role"><option value="operator">Operator</option><option value="viewer">Viewer</option></select><button type="submit">Add</button></form>
+</section>`;
+}
+
+/**
+ * The turn strip a signed-in non-member sees on a `queue` robot's panel: the active turn's countdown or their
+ * place in line, and a plain leave form that works without JavaScript (public/panel.js unhides it and paints
+ * the state). Hidden until a state frame says there is a queue view for this person.
+ */
+function turnStrip(robot, role) {
+ if (robot.access_policy !== 'queue' || role !== 'queue') return '';
+ return `<div class="turn" data-turn hidden><span class="turn-state" data-turn-state></span><span class="turn-meta" data-turn-meta></span><form method="post" action="/robots/${esc(robot.id)}/queue/leave" data-turn-leave><button type="submit" class="quiet">Leave the queue</button></form></div>`;
+}
+
+function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs = 150, mode = 'page', signedIn = true, streaming = null, people = 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 }))
@@ -270,13 +306,14 @@ ${embed ? '' : topbar(`<span class="crumb">${esc(robot.name)}</span>`)}
@@ -270,13 +306,14 @@ ${embed ? '' : topbar(`<span class="crumb">${esc(robot.name)}</span>`)}
<div class="panel-head">
<div class="panel-head">
<h1>${esc(robot.name)}</h1>
<h1>${esc(robot.name)}</h1>
<p class="meta"><span class="pill online" data-online-pill data-state="connecting"><span class="dot"></span><span data-online-label>Connecting…</span></span> <span class="pill">${esc(profile.name || robot.profile_id)}</span> <span class="pill role" data-role-label>${esc(ROLE_LABEL[role] || role)}</span></p>
<p class="meta"><span class="pill online" data-online-pill data-state="connecting"><span class="dot"></span><span data-online-label>Connecting…</span></span> <span class="pill">${esc(profile.name || robot.profile_id)}</span> <span class="pill role" data-role-label>${esc(ROLE_LABEL[role] || role)}</span></p>
+${turnStrip(robot, role)}
</div>
</div>
${embed && role === 'watcher' && !signedIn ? `<p class="embed-sign-in">${outLink(`/panel/${robot.id}`, 'Sign in to control')}</p>` : ''}
${embed && role === 'watcher' && !signedIn ? `<p class="embed-sign-in">${outLink(`/panel/${robot.id}`, 'Sign in to control')}</p>` : ''}
<p class="notice" data-notice role="alert" hidden></p>
<p class="notice" data-notice role="alert" hidden></p>
<div class="widgets">
<div class="widgets">
${widgets.join('\n')}
${widgets.join('\n')}
</div>
</div>
-${role === 'owner' && !embed ? `<div class="owner-settings">${embedForm(robot)}${streamingForm(robot, streaming)}</div>` : ''}
+${role === 'owner' && !embed ? `<div class="owner-settings">${embedForm(robot)}${streamingForm(robot, streaming)}${people ? peopleCard(robot, people) : ''}</div>` : ''}
</main>`;
</main>`;
return page(robot.name, body, { scripts: ['/panel/panel.js'], bodyClass: embed ? 'embed-page' : 'panel-page' });
return page(robot.name, body, { scripts: ['/panel/panel.js'], bodyClass: embed ? 'embed-page' : 'panel-page' });
}
}
@@ -15,6 +15,9 @@
@@ -15,6 +15,9 @@
* otherwise 403 with a link out. Never redirects to sign in; framed only by the
* otherwise 403 with a link out. Never redirects to sign in; framed only by the
* BOT_EMBED_ORIGINS allow-list (CSP frame-ancestors), every other page by itself
* BOT_EMBED_ORIGINS allow-list (CSP frame-ancestors), every other page by itself
* POST /robots/:id/embed the owner only: allow (embed_public=on) or stop (off) anonymous read-only embedding → 303 to its panel
* POST /robots/:id/embed the owner only: allow (embed_public=on) or stop (off) anonymous read-only embedding → 303 to its panel
+ * POST /robots/:id/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
* GET /panel/panel.js, /panel/panel.css the panel client and its sheet (public/, no build step)
* GET /panel/panel.js, /panel/panel.css the panel client and its sheet (public/, no build step)
*
*
* Not signed in → 302 to /auth/login?next=<the page>. No access → 403 (bot.not_an_operator / bot.forbidden).
* Not signed in → 302 to /auth/login?next=<the page>. No access → 403 (bot.not_an_operator / bot.forbidden).
@@ -33,7 +36,7 @@ const VERSION = require('../../package.json').version;
@@ -33,7 +36,7 @@ const VERSION = require('../../package.json').version;
const PUBLIC = path.join(__dirname, '..', '..', 'public');
const PUBLIC = path.join(__dirname, '..', '..', 'public');
const HOLD_RESEND_MS = 150;
const HOLD_RESEND_MS = 150;
-function createWebRoutes(config, { domain = null, sim = null, onvif = null, limits = null, log = console } = {}) {
+function createWebRoutes(config, { domain = null, sim = null, onvif = null, limits = null, identity = null, hub = null, log = console } = {}) {
const r = express.Router();
const r = express.Router();
r.get('/', (req, res) => res.type('text/plain').send(`OpenVibe.Bot ${VERSION} — ok (devices, pairing and control; API under /api/v1)\n`));
r.get('/', (req, res) => res.type('text/plain').send(`OpenVibe.Bot ${VERSION} — ok (devices, pairing and control; API under /api/v1)\n`));
r.get('/robots.txt', (req, res) => res.type('text/plain').send('User-agent: *\nDisallow: /\n'));
r.get('/robots.txt', (req, res) => res.type('text/plain').send('User-agent: *\nDisallow: /\n'));
@@ -175,6 +178,48 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
@@ -175,6 +178,48 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
res.redirect(303, `/panel/${robot.id}`);
res.redirect(303, `/panel/${robot.id}`);
}));
}));
+ // The owner adds a person to the robot by their OpenVibe username. Network resolves the @name to a subject;
+ // an unknown name is 422 with that name in the detail. The owner (already the owner row) may not be added.
+ r.post('/robots/:id/operators', ...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 add people');
+ const role = String((req.body && req.body.role) || '');
+ if (!['operator', 'viewer'].includes(role)) throw new BotError(422, 'bot.invalid_input', 'role must be operator or viewer');
+ const username = String((req.body && req.body.username) || '').trim().replace(/^@/, '');
+ if (!username) throw new BotError(422, 'bot.invalid_input', 'a username is required');
+ if (!identity) throw new BotError(503, 'bot.network_unavailable', 'Network is not reachable to resolve that name');
+ const user = await identity.byUsername(username);
+ if (!user) throw new BotError(422, 'bot.unknown_user', `No OpenVibe account is called @${username}`);
+ if (user.subject === me.subject) throw new BotError(422, 'bot.invalid_input', 'you are the owner of this robot');
+ await domain.members.add(robot.id, user.subject, role, me.subject);
+ res.redirect(303, `/panel/${robot.id}`);
+ }));
+
+ // The owner removes a person. The domain's delete never touches the owner row.
+ r.post('/robots/:id/operators/:subject/remove', ...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 remove people');
+ await domain.members.remove(robot.id, req.params.subject);
+ res.redirect(303, `/panel/${robot.id}`);
+ }));
+
+ // Leave the turn queue: anyone signed in, whether or not they are a member; the panels are told at once.
+ r.post('/robots/:id/queue/leave', ...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');
+ await domain.queue.leave(robot.id, me.subject);
+ if (hub) hub.broadcast(robot.id);
+ res.redirect(303, `/panel/${robot.id}`);
+ }));
+
/** As the /control join decides: a member's role, or a place in the queue on a `queue` robot; else null. */
/** As the /control join decides: a member's role, or a place in the queue on a `queue` robot; else null. */
async function memberRole(robot, me) {
async function memberRole(robot, me) {
const role = await domain.members.roleOf(robot.id, me.subject);
const role = await domain.members.roleOf(robot.id, me.subject);
@@ -188,14 +233,31 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
@@ -188,14 +233,31 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
const streaming = role === 'owner' && mode === 'page'
const streaming = role === 'owner' && mode === 'page'
? await domain.streaming.get(robot.id).catch(() => ({ available: false, reason: 'unavailable', media: { on: false }, live: { on: false } }))
? await domain.streaming.get(robot.id).catch(() => ({ available: false, reason: 'unavailable', media: { on: false }, live: { on: false } }))
: null;
: null;
+ // The owner's People card: every member but the owner, with Network's name for each. A Network that
+ // does not answer leaves the subject id and a note (see renderPanel) instead of failing the panel.
+ const people = role === 'owner' && mode === 'page' ? await peopleFor(robot) : null;
res.type('html').send(renderPanel({
res.type('html').send(renderPanel({
- robot: presentForPage(robot), profile, role, mode, signedIn, streaming,
+ robot: presentForPage(robot), profile, role, mode, signedIn, streaming, people,
allowed_commands: role === 'watcher' ? [] : domain.control.allowedFor(robot, role, profile),
allowed_commands: role === 'watcher' ? [] : domain.control.allowedFor(robot, role, profile),
// A held control is re-sent well inside the device's deadline, so it never stops between two frames.
// A held control is re-sent well inside the device's deadline, so it never stops between two frames.
holdResendMs: Math.max(50, Math.min(HOLD_RESEND_MS, Math.floor(maxCommandMs / 2))),
holdResendMs: Math.max(50, Math.min(HOLD_RESEND_MS, Math.floor(maxCommandMs / 2))),
}));
}));
}
}
+ /** The non-owner members with their Network names, or null names when Network cannot be asked. */
+ async function peopleFor(robot) {
+ const members = (await domain.members.list(robot.id)).filter((m) => m.subject !== robot.owner_subject);
+ let names = new Map();
+ if (members.length && identity) {
+ try { names = await identity.names(members.map((m) => m.subject)); }
+ catch (e) { log.warn(`[Bot] names for ${robot.id}: ${e.message}`); }
+ }
+ return members.map((m) => {
+ const n = names.get(m.subject);
+ return { subject: m.subject, role: m.role, username: n ? n.username : null, display_name: n ? n.display_name : null };
+ });
+ }
+
r.get('/panel/:id', page(async (req, res) => {
r.get('/panel/:id', page(async (req, res) => {
const me = requireUser(req);
const me = requireUser(req);
const robot = await domain.robots.get(req.params.id);
const robot = await domain.robots.get(req.params.id);
@@ -4,7 +4,10 @@
@@ -4,7 +4,10 @@
*
*
* startNetwork() JWKS and user/service JWTs (signUser, signService, newUser); node principals as
* startNetwork() JWKS and user/service JWTs (signUser, signService, newUser); node principals as
* GET /internal/node-principals/:id and POST …/:id/revoke answer them (addNode, signNode,
* GET /internal/node-principals/:id and POST …/:id/revoke answer them (addNode, signNode,
- * nodes, revokes), scoped to the calling service like Network's own routes
+ * nodes, revokes), scoped to the calling service like Network's own routes. Identity
+ * resolution for people (identity.subject.resolve): GET /internal/identity/resolve?username=
+ * and POST /internal/identity/resolve-batch, over the users newUser registers; failIdentity
+ * makes them answer a failure (Network down).
* startEvents() POST /api/v1/events recording what Bot's outbox relays; GET /api/health
* startEvents() POST /api/v1/events recording what Bot's outbox relays; GET /api/health
* startOpenRe() OpenRe.Stream's stream routes Bot calls (server/api/v1.js there): GET /api/v1/streams?external_ref=,
* startOpenRe() OpenRe.Stream's stream routes Bot calls (server/api/v1.js there): GET /api/v1/streams?external_ref=,
* GET /api/v1/streams/:id, POST /api/v1/streams, PATCH /api/v1/streams/:id (recording_mode /
* GET /api/v1/streams/:id, POST /api/v1/streams, PATCH /api/v1/streams/:id (recording_mode /
@@ -75,9 +78,24 @@ async function startNetwork() {
@@ -75,9 +78,24 @@ async function startNetwork() {
let pairingFailure = null;
let pairingFailure = null;
const failPairings = (status) => { pairingFailure = status; };
const failPairings = (status) => { pairingFailure = status; };
+ // Identity resolution for people (identity.subject.resolve). `users` is what newUser registered;
+ // failIdentity(status) makes both routes answer that status instead (Network down).
+ const users = new Map(); // subject → { subject, username, display_name }
+ const byName = new Map(); // lowercased username → subject
+ let identityFailure = null;
+ function rememberUser(u) { users.set(u.subject, u); byName.set(String(u.username).toLowerCase(), u.subject); }
function newUser(username) {
function newUser(username) {
- return { subject: ids.newId('user'), username, display_name: username[0].toUpperCase() + username.slice(1) };
+ const u = { subject: ids.newId('user'), username, display_name: username[0].toUpperCase() + username.slice(1) };
+ rememberUser(u);
+ return u;
}
}
+ const identityResolver = (req) => {
+ const h = String(req.headers.authorization || '');
+ if (!h.startsWith('Bearer ')) return false;
+ const r = serviceAuth.verifyServiceToken(h.slice(7), { publicKey: publicPem, issuer, audience: 'openvibe.network' });
+ return r.ok && (r.claims.cap || []).includes('identity.subject.resolve');
+ };
+ const identityView = (u) => ({ subject: u.subject, username: u.username, display_name: u.display_name });
const server = http.createServer(async (req, res) => {
const server = http.createServer(async (req, res) => {
if (req.url === '/api/.well-known/jwks') return send(res, 200, { public_key: publicPem, algorithm: 'RS256' });
if (req.url === '/api/.well-known/jwks') return send(res, 200, { public_key: publicPem, algorithm: 'RS256' });
@@ -88,6 +106,25 @@ async function startNetwork() {
@@ -88,6 +106,25 @@ async function startNetwork() {
const cap = String(body.scope || '').split(/\s+/).filter(Boolean);
const cap = String(body.scope || '').split(/\s+/).filter(Boolean);
return send(res, 200, { access_token: signService({ sub: `svc:${body.client_id}`, aud: [body.audience || 'openvibe.bot'], cap }), token_type: 'Bearer', expires_in: 300 });
return send(res, 200, { access_token: signService({ sub: `svc:${body.client_id}`, aud: [body.audience || 'openvibe.bot'], cap }), token_type: 'Bearer', expires_in: 300 });
}
}
+ const idn = new URL(req.url, 'http://network.test');
+ if (idn.pathname === '/internal/identity/resolve' && req.method === 'GET') {
+ if (!identityResolver(req)) return send(res, 401, { code: 'token.invalid' });
+ if (identityFailure) return send(res, identityFailure, { code: 'identity.failed' });
+ const subject = byName.get(String(idn.searchParams.get('username') || '').toLowerCase());
+ const u = subject && users.get(subject);
+ return u ? send(res, 200, identityView(u)) : send(res, 404, { code: 'identity.subject_not_found' });
+ }
+ if (idn.pathname === '/internal/identity/resolve-batch' && req.method === 'POST') {
+ if (!identityResolver(req)) return send(res, 401, { code: 'token.invalid' });
+ if (identityFailure) return send(res, identityFailure, { code: 'identity.failed' });
+ const body = JSON.parse((await readBody(req)) || '{}');
+ const results = {};
+ for (const id of (Array.isArray(body.subject_ids) ? body.subject_ids : [])) {
+ const u = users.get(String(id));
+ results[String(id)] = u ? identityView(u) : null;
+ }
+ return send(res, 200, { results });
+ }
if (req.url === '/internal/node-pairings' && req.method === 'POST') {
if (req.url === '/internal/node-pairings' && req.method === 'POST') {
const service = nodeManager(req);
const service = nodeManager(req);
if (!service) return send(res, 401, { code: 'token.invalid' });
if (!service) return send(res, 401, { code: 'token.invalid' });
@@ -114,7 +151,7 @@ async function startNetwork() {
@@ -114,7 +151,7 @@ async function startNetwork() {
});
});
const url = await listen(server);
const url = await listen(server);
issuer = url;
issuer = url;
- return { url, publicPem, signService, signUser, newUser, addNode, signNode, nodes, revokes, pairings, failPairings, close: () => new Promise((r) => server.close(r)) };
+ return { url, publicPem, signService, signUser, newUser, addNode, signNode, nodes, revokes, pairings, failPairings, users, failIdentity: (status) => { identityFailure = status; }, close: () => new Promise((r) => server.close(r)) };
}
}
async function startEvents() {
async function startEvents() {
@@ -103,6 +103,17 @@ const allowedFor = (profile, role) => [...(DEFAULT_ALLOW[role] || []).filter((k)
@@ -103,6 +103,17 @@ const allowedFor = (profile, role) => [...(DEFAULT_ALLOW[role] || []).filter((k)
}
}
});
});
+ await check('a person\'s subject is percent-encoded in the remove form action', () => {
+ const p = profiles.find((x) => x.id === 'sim.rover');
+ const subject = 'usr_a/b?c#d e&';
+ const html = renderPanel({
+ robot: robotFor(p), profile: p, role: 'owner', allowed_commands: [],
+ people: [{ subject, role: 'operator', username: 'x', display_name: 'X' }],
+ });
+ assert.ok(html.includes(`/operators/${encodeURIComponent(subject)}/remove`), 'the subject is not percent-encoded in the URL');
+ assert.ok(!html.includes(`/operators/${subject}/remove`), 'the raw subject is in the URL');
+ });
+
const t = await boot();
const t = await boot();
const alex = t.network.newUser('alex');
const alex = t.network.newUser('alex');
const bob = t.network.newUser('bob');
const bob = t.network.newUser('bob');
new file mode 100644
new file mode 100644
@@ -0,0 +1,246 @@
@@ -0,0 +1,246 @@
+'use strict';
+// Operator invites from the owner's panel (a @username resolved through Network) and the public turn queue's
+// leave route (plan T15): the People card, the three form posts, and the turn strip on a queue robot's panel.
+const fs = require('fs');
+const path = require('path');
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/app');
+const { createIdentity } = require('../server/network');
+
+/**
+ * A Network identity endpoint over a fake fetch, for the cache's own rules (subject keying, the TTL and the
+ * 2000-entry bound) without a server. `now` drives the TTL; the token endpoint answers once.
+ */
+function identityStub() {
+ let clock = 1_000_000;
+ const users = new Map(); // subject → { subject, username, display_name }
+ const names = new Map(); // lowercased username → subject
+ const calls = { resolve: 0, batch: 0 };
+ const reply = (status, body) => ({ ok: status >= 200 && status < 300, status, json: async () => body });
+ const fetchImpl = async (url, opts = {}) => {
+ const u = new URL(url);
+ if (u.pathname === '/oauth/token') return reply(200, { access_token: 'tok', token_type: 'Bearer', expires_in: 300 });
+ if (u.pathname === '/internal/identity/resolve' && opts.method === 'GET') {
+ calls.resolve++;
+ const subject = names.get(String(u.searchParams.get('username') || '').toLowerCase());
+ return subject ? reply(200, users.get(subject)) : reply(404, { code: 'identity.subject_not_found' });
+ }
+ if (u.pathname === '/internal/identity/resolve-batch') {
+ calls.batch++;
+ const results = {};
+ for (const id of (JSON.parse(opts.body || '{}').subject_ids || [])) results[id] = users.get(id) || null;
+ return reply(200, { results });
+ }
+ return reply(404, { code: 'not_found' });
+ };
+ const identity = createIdentity(
+ { network: { internalUrl: 'http://network.test' }, oauth: { clientId: 'bot', clientSecret: 'shh' } },
+ { fetchImpl, now: () => clock });
+ return {
+ identity, calls,
+ add: (username) => {
+ const u = { subject: `usr_${username}`, username, display_name: username[0].toUpperCase() + username.slice(1) };
+ users.set(u.subject, u);
+ names.set(username.toLowerCase(), u.subject);
+ return u;
+ },
+ now: () => clock,
+ setNow: (t) => { clock = t; },
+ };
+}
+
+(async () => {
+ const t = await boot();
+ const alex = t.network.newUser('alex');
+ const bob = t.network.newUser('bob');
+ const carol = t.network.newUser('carol');
+ const cookie = (user) => ({ Cookie: `ov_token=${t.network.signUser(user)}` });
+ const get = (p, user, headers = {}) => fetch(t.base + p, { redirect: 'manual', headers: { ...(user ? cookie(user) : {}), ...headers } });
+ const post = (p, user, form = {}, headers = {}) => fetch(t.base + p, {
+ method: 'POST', redirect: 'manual', body: new URLSearchParams(form).toString(),
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...(user ? cookie(user) : {}), ...headers },
+ });
+
+ const { robot } = await t.robot(alex, { name: 'Rover' });
+
+ await check('the owner sees the People card and adding @bob lists them, and the API agrees', async () => {
+ const html = await (await get(`/panel/${robot.id}`, alex)).text();
+ assert.ok(html.includes('class="setting-card people"') && html.includes('id="people-h"'));
+ assert.ok(html.includes('<p class="setting-note">Operators drive and can press the e-stop; viewers watch.</p>'));
+ assert.ok(html.includes('<li class="person"><span class="who"><b>You</b><small>Owner</small></span></li>'));
+ assert.ok(html.includes(`<form class="people-add" method="post" action="/robots/${robot.id}/operators">`));
+ assert.ok(html.includes('name="username" placeholder="@username" required maxlength="41"'));
+
+ const r = await post(`/robots/${robot.id}/operators`, alex, { username: '@bob', role: 'operator' });
+ assert.strictEqual(r.status, 303, await r.text());
+ assert.strictEqual(r.headers.get('location'), `/panel/${robot.id}`);
+
+ const after = await (await get(`/panel/${robot.id}`, alex)).text();
+ assert.ok(after.includes('<b>Bob</b><small>@bob · Operator</small>'), after.match(/<ul class="people-list">[\s\S]*?<\/ul>/));
+ assert.ok(after.includes(`action="/robots/${robot.id}/operators/${bob.subject}/remove"`));
+ const api = await t.call('GET', `/api/v1/robots/${robot.id}/operators`, { user: alex });
+ assert.ok(api.json.operators.some((o) => o.subject === bob.subject && o.role === 'operator'));
+ });
+
+ await check('unknown name, bad role and the owner adding themself are 422', async () => {
+ const unknown = await post(`/robots/${robot.id}/operators`, alex, { username: '@ghost', role: 'operator' });
+ assert.strictEqual(unknown.status, 422);
+ assert.match(await unknown.text(), /No OpenVibe account is called @ghost/);
+ assert.strictEqual((await post(`/robots/${robot.id}/operators`, alex, { username: '@carol', role: 'admin' })).status, 422);
+ assert.strictEqual((await post(`/robots/${robot.id}/operators`, alex, { username: '@alex', role: 'operator' })).status, 422);
+ });
+
+ await check('a Network 404 that is not "no such person", or a 400, is an outage (503), never unknown_user', async () => {
+ for (const status of [404, 400]) {
+ t.network.failIdentity(status);
+ try {
+ const r = await post(`/robots/${robot.id}/operators`, alex, { username: '@carol', role: 'viewer' });
+ const body = await r.text();
+ assert.strictEqual(r.status, 503, `${status}: ${body}`);
+ assert.match(body, /Network answered/);
+ } finally { t.network.failIdentity(null); }
+ }
+ // The only 404 that means "no such account" is Network's own identity.subject_not_found (the 422 above).
+ });
+
+ await check('a non-owner cannot add or remove people (and asks Network nothing)', async () => {
+ t.network.failIdentity(500);
+ try {
+ assert.strictEqual((await post(`/robots/${robot.id}/operators`, bob, { username: '@carol', role: 'viewer' })).status, 403);
+ assert.strictEqual((await post(`/robots/${robot.id}/operators/${bob.subject}/remove`, bob)).status, 403);
+ } finally { t.network.failIdentity(null); }
+ });
+
+ await check('the owner removes a person; the owner row stays', async () => {
+ assert.strictEqual((await post(`/robots/${robot.id}/operators/${alex.subject}/remove`, alex)).status, 303);
+ assert.ok((await t.domain.members.list(robot.id)).some((m) => m.subject === alex.subject && m.role === 'owner'));
+ assert.strictEqual((await post(`/robots/${robot.id}/operators/${bob.subject}/remove`, alex)).status, 303);
+ assert.ok(!(await t.domain.members.list(robot.id)).some((m) => m.subject === bob.subject));
+ });
+
+ await check('removing an operator drops their live /control socket (form and API)', async () => {
+ const { robot: r } = await t.robot(alex, { name: 'Watch' });
+ assert.strictEqual((await post(`/robots/${r.id}/operators`, alex, { username: '@bob', role: 'operator' })).status, 303);
+ const viaForm = await t.ws('/control', { headers: cookie(bob) });
+ viaForm.send({ type: 'join', robot_id: r.id });
+ assert.strictEqual((await viaForm.waitFor((m) => m.type === 'joined')).role, 'operator');
+ assert.strictEqual((await post(`/robots/${r.id}/operators/${bob.subject}/remove`, alex)).status, 303);
+ assert.strictEqual(await viaForm.waitForClose(), 4003, 'the removed operator\'s socket stayed open');
+
+ await t.domain.members.add(r.id, bob.subject, 'operator', alex.subject);
+ const viaApi = await t.ws('/control', { headers: cookie(bob) });
+ viaApi.send({ type: 'join', robot_id: r.id });
+ assert.strictEqual((await viaApi.waitFor((m) => m.type === 'joined')).role, 'operator');
+ const del = await t.call('DELETE', `/api/v1/robots/${r.id}/operators/${bob.subject}`, { user: alex });
+ assert.strictEqual(del.status, 200, del.text);
+ assert.strictEqual(await viaApi.waitForClose(), 4003, 'the API-removed operator\'s socket stayed open');
+ });
+
+ await check('Network down: the panel still renders, with the subject id and "name unavailable"', async () => {
+ await t.domain.members.add(robot.id, carol.subject, 'viewer', alex.subject);
+ t.network.failIdentity(500);
+ const r = await get(`/panel/${robot.id}`, alex);
+ assert.strictEqual(r.status, 200);
+ const html = await r.text();
+ assert.ok(html.includes(`<b>${carol.subject}</b><small>name unavailable</small>`), html.match(/<ul class="people-list">[\s\S]*?<\/ul>/));
+ t.network.failIdentity(null);
+ const back = await (await get(`/panel/${robot.id}`, alex)).text();
+ assert.ok(back.includes('<b>Carol</b><small>@carol · Viewer</small>'));
+ });
+
+ await check('a queue robot: a signed-in non-member gets the turn strip; the owner does not', async () => {
+ const { robot: q } = await t.robot(alex, { name: 'Queue', access_policy: 'queue' });
+ const html = await (await get(`/panel/${q.id}`, bob)).text();
+ assert.ok(html.includes('<div class="turn" data-turn hidden><span class="turn-state" data-turn-state></span><span class="turn-meta" data-turn-meta></span>'));
+ assert.ok(html.includes(`<form method="post" action="/robots/${q.id}/queue/leave" data-turn-leave>`));
+ const owner = await (await get(`/panel/${q.id}`, alex)).text();
+ assert.ok(!owner.includes('data-turn'), 'the owner sees no leave strip');
+ assert.ok(owner.includes('Anyone signed in can also take a turn'), 'the queue note is on the owner card');
+
+ // Bob joins on /control (the queue's own door) and becomes the active turn; Carol waits.
+ const c = await t.ws('/control', { headers: cookie(bob) });
+ c.send({ type: 'join', robot_id: q.id });
+ const joined = await c.waitFor((m) => m.type === 'joined');
+ assert.strictEqual(joined.role, 'queue');
+ // A queue member's frame carries their own subject and no other: never the active driver's.
+ assert.strictEqual(joined.state.queue.subject, bob.subject);
+ assert.ok(!('turn_subject' in joined.state.queue), 'a queue member learned another subject');
+ await t.domain.queue.join(q.id, carol.subject);
+ const active = await t.domain.queue.state(q.id, bob.subject);
+ assert.strictEqual(active.active, true);
+ assert.ok(!('turn_subject' in active), 'the active driver\'s subject leaked into a queue view');
+ const waiting = await t.domain.queue.state(q.id, carol.subject);
+ assert.strictEqual(waiting.subject, carol.subject);
+ assert.ok(!('turn_subject' in waiting), 'a waiting member learned the driver\'s subject');
+
+ let broadcasts = 0;
+ const original = t.hub.broadcast;
+ t.hub.broadcast = (id) => { if (id === q.id) broadcasts++; return original.call(t.hub, id); };
+ try {
+ const r = await post(`/robots/${q.id}/queue/leave`, bob);
+ assert.strictEqual(r.status, 303);
+ assert.strictEqual(r.headers.get('location'), `/panel/${q.id}`);
+ } finally { t.hub.broadcast = original; }
+ assert.strictEqual(broadcasts >= 1, true, 'the panels were not told');
+
+ assert.strictEqual(await t.db.maybe('SELECT * FROM robot_queue WHERE robot_id = $1 AND subject = $2', [q.id, bob.subject]), null);
+ const left = await t.domain.queue.state(q.id, bob.subject);
+ assert.strictEqual(left.active, false);
+ assert.strictEqual(left.position, null);
+ const promoted = await t.domain.queue.state(q.id, carol.subject);
+ assert.strictEqual(promoted.active, true);
+ assert.strictEqual(promoted.position, 0);
+ c.close();
+ });
+
+ await check('the panel client paints the strip from a state frame\'s queue', () => {
+ const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'panel.js'), 'utf8');
+ assert.match(js, /paintTurn\(m\.state && m\.state\.queue\)/);
+ assert.match(js, /'Your turn'/);
+ assert.match(js, /in line/);
+ assert.match(js, /setInterval\(paintLeft, 1000\)/);
+ });
+
+ await check('the identity cache is keyed by subject', async () => {
+ const s = identityStub();
+ const bob = s.add('bob');
+ const resolved = await s.identity.byUsername('bob');
+ assert.strictEqual(resolved.subject, bob.subject);
+ assert.strictEqual(s.calls.resolve, 1);
+ // The resolution is cached under the subject: names() for it needs no batch call.
+ const cached = await s.identity.names([bob.subject]);
+ assert.strictEqual(cached.get(bob.subject).username, 'bob');
+ assert.strictEqual(s.calls.batch, 0, 'a resolved subject was looked up again');
+ // A subject never resolved is a miss.
+ const carol = s.add('carol');
+ const fresh = await s.identity.names([carol.subject]);
+ assert.strictEqual(fresh.get(carol.subject).username, 'carol');
+ assert.strictEqual(s.calls.batch, 1);
+ });
+
+ await check('the identity cache expires after its TTL', async () => {
+ const s = identityStub();
+ const bob = s.add('bob');
+ await s.identity.names([bob.subject]);
+ await s.identity.names([bob.subject]);
+ assert.strictEqual(s.calls.batch, 1, 'a second lookup inside the TTL was not cached');
+ s.setNow(s.now() + 5 * 60 * 1000 + 1);
+ await s.identity.names([bob.subject]);
+ assert.strictEqual(s.calls.batch, 2, 'an expired entry was still served');
+ });
+
+ await check('the identity cache holds at most 2000 subjects', async () => {
+ const s = identityStub();
+ const subjects = [];
+ for (let i = 0; i < 2001; i++) subjects.push(s.add(`u${i}`).subject);
+ await s.identity.names(subjects);
+ const filled = s.calls.batch;
+ await s.identity.names([subjects[0]]); // the oldest was evicted at the bound
+ assert.strictEqual(s.calls.batch, filled + 1, 'the cache grew past 2000 entries');
+ await s.identity.names([subjects[2000]]); // the newest is still held
+ assert.strictEqual(s.calls.batch, filled + 1);
+ });
+
+ await t.close();
+ done();
+})();