openvibers/openvibe.bot Commits

Add operator invites by @username and the public turn queue to the panel (#32)

OP OpenVibers committed 5 Oct 2026 · c4bedbb483 · 14 files, +570 −19

* Add operator invites by @username and the public turn queue to the panel

All tests pass. Here's the summary.

## Files changed

- **server/network.js** — added `createIdentity(config, { fetchImpl, now })`: a `serviceAuth` token client (audience `openvibe.network`, scope `identity.subject.resolve`) with `byUsername(name)` (GET `/internal/identity/resolve?username=`, null on 404) and `names(subjectIds)` (POST `/internal/identity/resolve-batch`, `{ results }` → Map), cached 5 min / ≤2000 entries; any other failure throws `503 bot.network_unavailable`. Exported it.
- **server/app.js** — created `identity` where `createNodePrincipals` is created and passed it (plus `hub`) to `createWebRoutes`.
- **server/domain/index.js** — added `leaveQueue(robotId, subject)`: deletes the subject's `robot_queue` row and runs `currentTurn` (which promotes the oldest waiting person when the active turn ended); exported as `domain.queue.leave`.
- **server/web/routes.js** — new form routes `POST /robots/:id/operators` (owner only; strips `@`, validates role → 422 `bot.invalid_input`, resolves via `identity.byUsername`, unknown → 422 `bot.unknown_user` with `No OpenVibe account is called @<name>`, self-add → 422, then `domain.members.add`), `POST /robots/:id/operators/:subject/remove` (owner only; `domain.members.remove`, owner row untouched), `POST /robots/:id/queue/leave` (any signed-in person; `domain.queue.leave` then `hub.broadcast(robot.id)`). All use `...manage`, `express.urlencoded`, `sameOrigin`, `requireUser`, 303. `sendPanel` now builds `people` (owner page only) via `identity.names`, falling back to null names if Network is unreachable; `peopleFor` helper added.
- **server/web/render.js** — `renderPanel` takes `people`; `peopleCard` renders the exact People markup after the streaming card (queue note only when `access_policy === 'queue'`, subject-id + "name unavailable" fallback, everything escaped); `turnStrip` renders the exact `data-turn` markup under the meta pills for `role === 'queue'` on a queue robot.
- **public/panel.js** — `paintTurn` reads every `robot_state`/`joined` frame's `queue`: active → "Your turn" + "{remaining}s left · {used} of {budget} commands" with a 1 s interval, position > 0 → "You're #N in line", neither → hidden; timer cleared on turn end and socket close.
- **public/panel.css** — appended the given People/turn CSS including `.sr-only` (not previously defined).
- **test/helpers/stubs.js** — Network stub now registers `newUser`s and answers `GET /internal/identity/resolve?username=` and `POST /internal/identity/resolve-batch` (guarded by `identity.subject.resolve`), plus `failIdentity(status)` for a Network-down test.
- **test/people.test.js** (new) — People card + add/list/API, unknown name, bad role, self-add, non-owner 403s, owner row survives removal, Network-down fallback, queue-robot strip markup, queue leave removes/promotes with a broadcast assertion, and panel.js source assertions.
- **README.md** — feature bullet for People invites and turn-queue leave; Network dependency mentions identity resolution.
- **STATUS.json** — one feature line for the panel People card and queue leave.
- **docs/protocol.md** — the three web form routes added to the "not in /api/v1" list.

## Tests run

- `ov test test/people.test.js test/panel.test.js` → **2 passed in 16 s**
- `ov test test/node-principal.test.js test/embed-panel.test.js test/docs.test.js` → **3 passed in 11 s**
- `ov test test/api.test.js test/owner-fence.test.js test/streaming.test.js` → **3 passed in 17 s**

## Not done / notes

- No comm

* People: drop a removed or downgraded member's socket, hide other drivers' subjects, strict 404s, encoded subjects (review)

All five fixes are in, verified by the three files. Final answer:

**Changes**

1. `server/realtime.js:601` — added `dropSubject(robotId, subject, reason)`: closes every non-watcher socket of that subject on the robot with 4003 + reason and unsubscribes it; exported at `server/realtime.js:724`.
2. `server/domain/index.js:121` — shared `membershipChanged()` domain hook (calls `link.dropSubject` then `link.broadcast`); invoked at `:109` when an add changes the role and at `:114` when a non-owner row is removed — so both the web form routes and the v1 API get it.
3. `server/domain/index.js:624` — `queueState` no longer emits `turn_subject` (nothing in `public/panel.js` reads it); a member sees only their own subject/position/turn. `docs/protocol.md:326` updated to match.
4. `server/network.js:147` — only a 404 whose body code is `identity.subject_not_found` returns null; any other 404 (including a Network without the `?username=` route) or a 400 now throws 503 `bot.network_unavailable`.
5. `server/web/render.js:266` — the remove form's action now percent-encodes the subject with `encodeURIComponent`.

**Tests added**
- `test/people.test.js` — removing an operator drops their live `/control` socket (4003) via both the form and `DELETE /api/v1/…`; a queue member's joined/active/waiting frames carry no other subject; Network 404 (other code) and 400 answer 503 while `identity.subject_not_found` stays 422; a non-owner's add form answers 403 with Network down (no lookup); identity cache is subject-keyed, expires after its TTL, and evicts at 2000 entries.
- `test/panel.test.js:106` — the People remove URL contains the `encodeURIComponent` subject.

**Test results**

`ov test test/people.test.js test/panel.test.js test/control.test.js` → **3 passed in 31 s** (per-check reruns: people 12/12, panel 16/16 all passed). Nothing committed or pushed.

Changes

ddiff --git a/README.md b/README.md
iindex b646cda..6bb9240 100644
---- a/README.md
++++ b/README.md
@@@ -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).
ddiff --git a/STATUS.json b/STATUS.json
iindex 4d6529d..ffb1269 100644
---- a/STATUS.json
++++ b/STATUS.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)",
ddiff --git a/docs/protocol.md b/docs/protocol.md
iindex acd4c80..4cd48dd 100644
---- a/docs/protocol.md
++++ b/docs/protocol.md
@@@ -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`).
ddiff --git a/public/panel.css b/public/panel.css
iindex caaa292..ef13991 100644
---- a/public/panel.css
++++ b/public/panel.css
@@@ -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; }
ddiff --git a/public/panel.js b/public/panel.js
iindex 179d51f..53ace66 100644
---- a/public/panel.js
++++ b/public/panel.js
@@@ -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…');
ddiff --git a/server/app.js b/server/app.js
iindex a3806f1..6961a49 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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 });
ddiff --git a/server/domain/index.js b/server/domain/index.js
iindex 6a8b0b1..3ed1195 100644
---- a/server/domain/index.js
++++ b/server/domain/index.js
@@@ -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 },
};
ddiff --git a/server/network.js b/server/network.js
iindex ebdc905..a8fc8af 100644
---- a/server/network.js
++++ b/server/network.js
@@@ -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 };
ddiff --git a/server/realtime.js b/server/realtime.js
iindex 668ddd5..1c35a2e 100644
---- a/server/realtime.js
++++ b/server/realtime.js
@@@ -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 */ } }
}
++ /**
++ * 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 = () => Dat
bindDomain(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 })); },
ddiff --git a/server/web/render.js b/server/web/render.js
iindex d017daa..9ff8593 100644
---- a/server/web/render.js
++++ b/server/web/render.js
@@@ -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' });
}
ddiff --git a/server/web/routes.js b/server/web/routes.js
iindex 6f556bb..2fd7e13 100644
---- a/server/web/routes.js
++++ b/server/web/routes.js
@@@ -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, limi
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. */
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, limi
const 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);
ddiff --git a/test/helpers/stubs.js b/test/helpers/stubs.js
iindex c465074..f9646c4 100644
---- a/test/helpers/stubs.js
++++ b/test/helpers/stubs.js
@@@ -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() {
ddiff --git a/test/panel.test.js b/test/panel.test.js
iindex 7eb0a1a..f29f8bf 100644
---- a/test/panel.test.js
++++ b/test/panel.test.js
@@@ -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');
ddiff --git a/test/people.test.js b/test/people.test.js
nnew file mode 100644
iindex 0000000..97aac2e
---- /dev/null
++++ b/test/people.test.js
@@@ -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();
++})();