openvibers/openvibe.bot Commits

Issue robot publish keys from OpenRe and require openre.stream.read (#17)

OP OpenVibers committed 4 Oct 2026 · 07edb3c9e3 · 15 files, +679 −49

local checks ✓ 4/4 · no CI checks · review SHIP by DeepSeek harness · 1 fix round

https://github.com/OpenVibers/OpenVibe.Bot/pull/17

Changes

ddiff --git a/.env.example b/.env.example
iindex b1513bb..24fd7b4 100644
---- a/.env.example
++++ b/.env.example
@@@ -66,4 +66,12 @@ OV_OAUTH_CLIENT_SECRET=
# github.com, objects.githubusercontent.com or openvibe.bot only; anything else refuses to boot.
#BOT_INSTALLER_SOURCE_URL=https://raw.githubusercontent.com/OpenVibers/OpenVibe.Node/main/install/install.sh
# The WHIP ingest base each device publishes to; whip_url = <base>/<publish key>. Unset or empty: pair without video.
++# In production this is OpenRe's WHIP ingest, https://ingest.openre.stream/whip (O30); the key must be one
++# OpenRe issued (BOT_OPENRE_* below), or the ingest refuses the device.
#BOT_WHIP_BASE=https://example.invalid/whip
++# OpenRe.Stream, which issues each robot's publish key (its stream's ingest key). The token is a Network
++# service token holding openre.stream.read, openre.stream.write and openre.key.rotate (or openre.stream.*
++# with openre.key.rotate). Either unset: devices pair without video.
++#BOT_OPENRE_URL=
++#BOT_OPENRE_TOKEN=
++#BOT_OPENRE_TIMEOUT_MS=8000
ddiff --git a/README.md b/README.md
iindex df2a336..744560d 100644
---- a/README.md
++++ b/README.md
@@@ -60,7 +60,9 @@ To run the schema migrations before the service serves, start it once with the d
Set `BOT_PAIRING_AUTHORITY` to `bot` (the default) or `network`; any other value refuses to boot.
--Set `BOT_WHIP_BASE` in `/etc/openvibe/bot.env` to the WHIP ingest base each device publishes to (`whip_url = <base>/<publish key>`, sent once with the pairing); leave it unset to pair devices without video.
++Set `BOT_WHIP_BASE` in `/etc/openvibe/bot.env` to the WHIP ingest base each device publishes to (`whip_url = <base>/<publish key>`, sent once with the pairing; OpenRe's is `https://ingest.openre.stream/whip`); leave it unset to pair devices without video.
++
++The publish key is always an ingest key OpenRe.Stream issued for the robot's stream (OpenRe's WHIP ingest admits no other): set `BOT_OPENRE_URL` and `BOT_OPENRE_TOKEN` (a Network service token holding `openre.stream.read`, `openre.stream.write` and `openre.key.rotate`, or `openre.stream.*` with `openre.key.rotate`; `BOT_OPENRE_TIMEOUT_MS`, default 8000, bounds each call). Pairing creates the robot's OpenRe stream (or rotates the one it has), a credential rotation rotates its key, revoking a device or removing a robot revokes the key and ends the live session. Bot stores the stream id and the key's hint, never the key. With either variable unset, devices still pair but get no publish key and the answer says `"video": "not_configured"`; Bot mints no key of its own.
`/install` is proxied to the app, which answers a 302 to OpenVibe.Node's installer script (`BOT_INSTALLER_SOURCE_URL`, by default `install/install.sh` on OpenVibe.Node's `main`; https on an allow-listed GitHub or openvibe.bot host, checked at boot, and no query parameter changes the target). The pairing's installer command adds `--driver adeept|adeept-mecanum|cozmo` for those profiles and nothing for the others (the dry-run `none`).
ddiff --git a/docs/cutover-pr-openvibe-bot-17.md b/docs/cutover-pr-openvibe-bot-17.md
nnew file mode 100644
iindex 0000000..ab14a86
---- /dev/null
++++ b/docs/cutover-pr-openvibe-bot-17.md
@@@ -0,0 +1,60 @@
++# PR 17 OpenRe publish-key cutover
++
++This PR makes a robot's WHIP publish key an ingest key OpenRe.Stream issues for the robot's stream, instead of
++a key Bot mints and hashes itself. Its schema change is `migrations/0003_openre_stream.sql` (phase `expand`):
++it only **adds** two nullable columns, `robots.openre_stream_id` and `devices.publish_key_hint`. There is no
++backfill, no `NOT NULL`, no index and no rewrite of existing rows, so the previous Bot revision keeps working
++against the new schema. Bot applies the migration at startup through its usual migration step.
++
++New configuration (names only): `BOT_OPENRE_URL`, `BOT_OPENRE_TOKEN` (a Network service token holding
++`openre.stream.read`, `openre.stream.write` and `openre.key.rotate`), optional `BOT_OPENRE_TIMEOUT_MS`
++(default 8000), and `BOT_WHIP_BASE=https://ingest.openre.stream/whip` in production. With `BOT_OPENRE_URL` or
++`BOT_OPENRE_TOKEN` unset, pairing still succeeds and answers `"video": "not_configured"`; Bot mints no key.
++
++## Rehearsal on a copy
++
++Use an isolated copy of Bot's PostgreSQL data and the same deployment configuration as production, with test
++credentials and endpoints (a test OpenRe.Stream instance or the stub, never production OpenRe). The rehearsal
++name for the cutover gate is `pr_OpenVibe.Bot_17`.
++
++1. Record the deployed Bot revision and take a restorable PostgreSQL backup using the normal backup workflow.
++ Confirm the backup restores to the isolated copy before proceeding.
++2. At the isolated deployment, set `BOT_OPENRE_URL`/`BOT_OPENRE_TOKEN` to the test endpoint and
++ `BOT_WHIP_BASE` to the test WHIP base. Deploy PR 17 through the normal pipeline; Bot applies migration
++ 0003 at startup. Check `/api/ready` and `/api/health`.
++3. Confirm the schema: `robots.openre_stream_id` and `devices.publish_key_hint` exist, both nullable, with no
++ values changed in existing rows.
++4. Pair a test robot. Confirm HTTP 201 with a `publish_key` OpenRe admits, `whip_url` equal to
++ `BOT_WHIP_BASE/<publish_key>`, and no `video` field. Confirm `robots.openre_stream_id` holds the OpenRe
++ stream id, `devices.publish_key_hint` the key's last characters, and `devices.publish_key_hash` is null;
++ the key itself appears in no table, log or read.
++5. Re-pair the same robot and rotate the device credential. Confirm OpenRe rotated the same stream (no second
++ stream definition is created) and the previous key stops being admitted, then keeps working only through
++ the rotation's grace.
++6. Revoke the device: confirm OpenRe rotated with no grace and ended the live session. Remove the robot:
++ confirm the stream is archived when nothing is live.
++7. Restart with `BOT_OPENRE_URL`/`BOT_OPENRE_TOKEN` unset and pair. Confirm HTTP 201 with
++ `"video": "not_configured"`, no `publish_key`/`whip_url`, and no local key minted.
++8. Point `BOT_OPENRE_URL` at an unreachable host and pair. Confirm a clean `503 bot.openre_unavailable`, the
++ pairing code left unused, and no device row written.
++9. Revert the isolated deployment to the recorded prior revision. Confirm readiness and that the existing
++ robot and device rows are intact. Rehearsal operators, not this PR, publish the result at
++ `ds/deploy/rehearsals/pr_OpenVibe.Bot_17.json` on the harness with `{"ok": true}` only after every check
++ passes.
++
++## Production order and way back
++
++1. Confirm OpenRe.Stream is reachable and that the Network service token holds `openre.stream.read`,
++ `openre.stream.write` and `openre.key.rotate`. Record the current Bot revision and take a fresh, restorable
++ PostgreSQL backup before the guarded Bot deployment.
++2. Add `BOT_OPENRE_URL`, `BOT_OPENRE_TOKEN` and `BOT_WHIP_BASE=https://ingest.openre.stream/whip` (and
++ `BOT_OPENRE_TIMEOUT_MS` if the default 8000 ms is wrong) to `/etc/openvibe/bot.env` **before** deploying.
++3. Deploy the reviewed revision through the merge pipeline. Confirm `/api/ready`, `/api/health`, and the two
++ new columns. Pair one controlled test robot with a test owner, confirm its key is admitted by OpenRe and
++ stored only as a hint, then remove the robot so its stream is archived.
++4. If the OpenRe wiring is wrong, unset `BOT_OPENRE_*` and restart: pairing still works and answers
++ `"video": "not_configured"`, and already-issued OpenRe keys keep publishing until rotated. Roll Bot back to
++ the recorded prior revision through the deployment pipeline; migration 0003 is expand-only, so the prior
++ revision simply ignores the two new columns and no schema reversal or data restore is needed for a code
++ rollback. Restore the backup only if an unrelated data incident requires it, coordinating with the database
++ owner because a restore discards writes made after the snapshot.
ddiff --git a/docs/protocol.md b/docs/protocol.md
iindex 58745d3..095d2fc 100644
---- a/docs/protocol.md
++++ b/docs/protocol.md
@@@ -64,7 +64,7 @@ At most 64 frames wait; any more are answered `error` `bot.not_ready`.
| type | fields (besides v, seq, ts) | when |
|---|---|---|
--| `paired` | `device_id, credential, publish_key, whip_url, robot_ids, profile_id, profile` | the answer to `pair`; `credential`/`publish_key`/`whip_url` are shown **once** |
++| `paired` | `device_id, credential, publish_key, whip_url, robot_ids, profile_id, profile` | the answer to `pair`; `credential`/`publish_key`/`whip_url` are shown **once**; without OpenRe configured `video: "not_configured"` instead of `publish_key`/`whip_url` |
| `hello` | `session_id, device_id, robot_ids, server_time` | on every authenticated connection |
| `config` | `heartbeat_ms, limits, allowed_commands, estop_latched` | right after `hello`, and again whenever the owner changes the robot's limits (or `allow` lists) and whenever Bot's latch is set or cleared (after the `estop` frame) |
| `command` | `id, kind, value, deadline_ms, operator{subject,role}, robot_id` | an operator's command passed the gate |
@@@ -112,6 +112,18 @@ only by `pair` / `POST /pair` and by a rotation (which issues a new key, so a ne
With `BOT_WHIP_BASE` unset or empty the field is left out of the answer entirely and the device runs
without video.
++`publish_key` is an ingest key OpenRe.Stream issued, the only kind its WHIP ingest admits: each robot has one
++OpenRe stream (external ref `bot:robot:<robot id>`, protocol `webrtc`, no recording), created at its first
++pairing with `BOT_OPENRE_URL`/`BOT_OPENRE_TOKEN` set and found again by that ref (the token needs
++`openre.stream.read`, `openre.stream.write` and `openre.key.rotate`; `openre.stream.*` covers the first two).
++A pairing or `POST /devices/bind` rotates the stream's key with no grace (the robot's previous device stops publishing), a
++credential rotation with the credential's grace, a revocation with no grace and the live session ended;
++removing the robot does the same and archives the stream when nothing is live. Bot stores the stream id and
++the key's hint, never the key. OpenRe refusing is `502 bot.openre_refused` (its problem code in `detail`),
++not answering `503 bot.openre_unavailable`; a pairing that fails so leaves the code unused and no device.
++With `BOT_OPENRE_URL` or `BOT_OPENRE_TOKEN` unset the answers carry `"video": "not_configured"` and neither
++`publish_key` nor `whip_url`.
++
### 1.1 Command values (the profile's `commands`)
Each profile (`server/profiles/*.json`) declares, per kind, what its device accepts; the gate allows only
@@@ -337,9 +349,9 @@ routes. Both come before any lookup, so a real robot or device id answers exactl
| `POST /robots/:id/operators` | owner, or `bot.robot.manage` | `201 { operators[] }` |
| `DELETE /robots/:id/operators/:subject` | owner, or `bot.robot.manage` | `{ operators[] }` |
| `GET /robots/:id/devices` | member, or `bot.robot.read` | `{ devices[] }` (no hashes, `online`) |
--| `POST /devices/:id/rotate` | the robot's owner, `bot.device.connect` | `{ device, credential, publish_key, whip_url }` (once); for a Network-paired machine `{ device, sent }` — the machine is sent `rotate`, no credential is answered |
--| `POST /devices/:id/revoke` | the robot's owner, `bot.device.connect` | `{ device }`; the socket closes at once. A Network-paired machine's principal is revoked on Network too (`503 bot.network_unavailable` if Network did not answer: revoked here, retry) and never binds again |
--| `POST /devices/bind` | a Network node token (audience `openvibe.bot`), no body | `201 { device_id, publish_key, whip_url, robot_id, profile }` — `POST /pair`'s answer without `credential`; again → the same device and a new publish key (the old one stops working). Refused: `401 bot.node_token_required`, `403 bot.node_not_bound` |
++| `POST /devices/:id/rotate` | the robot's owner, `bot.device.connect` | `{ device, credential, publish_key, whip_url }` (once; `video: "not_configured"` instead of the key without OpenRe); for a Network-paired machine `{ device, sent }` — the machine is sent `rotate`, no credential is answered |
++| `POST /devices/:id/revoke` | the robot's owner, `bot.device.connect` | `{ device }`; the socket closes at once. A Network-paired machine's principal is revoked on Network too (`503 bot.network_unavailable` if Network did not answer: revoked here, retry) and never binds again. The OpenRe key the device holds is revoked and its live session ended (`502`/`503 bot.openre_*` if OpenRe did not: revoked here, retry) |
++| `POST /devices/bind` | a Network node token (audience `openvibe.bot`), no body | `201 { device_id, publish_key, whip_url, robot_id, profile }` — `POST /pair`'s answer without `credential`; again → the same device and a new publish key (the old one stops working); without OpenRe `video: "not_configured"` instead of the key. Refused: `401 bot.node_token_required`, `403 bot.node_not_bound` |
| `POST /robots/:id/estop` | owner/operator, `bot.robot.control` | `{ robot }` |
| `POST /robots/:id/estop/clear` | **owner only**, `bot.robot.control` | `{ robot }` |
| `GET /robots/:id/audit?limit=&before=` | owner, or `bot.robot.read` | `{ audit[], next_before }` (newest first) |
ddiff --git a/migrations/0003_openre_stream.sql b/migrations/0003_openre_stream.sql
nnew file mode 100644
iindex 0000000..0af1ef7
---- /dev/null
++++ b/migrations/0003_openre_stream.sql
@@@ -0,0 +1,9 @@
++-- phase: expand
++--
++-- The robot's OpenRe stream (T15 R5): OpenRe's WHIP worker admits only its own ingest keys, so a device's
++-- publish key is the ingest key of its robot's OpenRe stream. Bot keeps the stream's id and, per device, the
++-- key's hint (its last characters, as OpenRe shows them); never the key. Additive only: publish_key_hash stays
++-- for rows written before this and is no longer written.
++
++ALTER TABLE robots ADD COLUMN openre_stream_id text; -- OpenRe stream definition id; NULL until a device gets a key
++ALTER TABLE devices ADD COLUMN publish_key_hint text; -- hint of the OpenRe key this device holds; NULL: it holds none
ddiff --git a/server/api/v1.js b/server/api/v1.js
iindex 60b8efd..4ad7048 100644
---- a/server/api/v1.js
++++ b/server/api/v1.js
@@@ -186,7 +186,7 @@ function v1Router({ domain, apiAuth, limits, hub }) {
return res.json({ device: domain.present.device(d), sent });
}
const rotated = await domain.devices.rotate(req.params.id);
-- res.json({ device: domain.present.device(rotated.device), credential: rotated.credential, publish_key: rotated.publish_key, ...(rotated.whip_url ? { whip_url: rotated.whip_url } : {}) });
++ res.json({ device: domain.present.device(rotated.device), credential: rotated.credential, ...domain.present.video(rotated) });
}));
r.post('/devices/:id/revoke', manage, wrap(async (req, res) => {
fence(req);
@@@ -194,11 +194,13 @@ function v1Router({ domain, apiAuth, limits, hub }) {
if (!d) fail(404, 'bot.device_not_found', 'no such device');
await owner(req, json(d.robot_ids, [])[0]);
const revoked = await domain.devices.revoke(req.params.id);
-- // A Network-paired machine is revoked on Network too (Bot never binds that principal again either way).
++ // A Network-paired machine is revoked on Network too (Bot never binds that principal again either way),
++ // and the publish key it holds on OpenRe is revoked with its live session ended.
let networkError = null;
if (d.node_principal) await domain.devices.revokeNode(d.node_principal).catch((e) => { networkError = e; });
++ await domain.devices.revokeVideo(req.params.id).catch((e) => { networkError = networkError || e; });
hub.closeDevice(req.params.id, 'revoked'); // revocation disconnects the device at once
-- if (networkError) throw networkError; // revoked here; the owner retries for Network
++ if (networkError) throw networkError; // revoked here; the owner retries for Network or OpenRe
res.json({ device: domain.present.device(revoked) });
}));
@@@ -238,7 +240,7 @@ function v1Router({ domain, apiAuth, limits, hub }) {
device_kind: req.body?.device_kind || 'onboard', drivers: Array.isArray(req.body?.drivers) ? req.body.drivers : [],
capabilities: req.body?.capabilities && typeof req.body.capabilities === 'object' ? req.body.capabilities : {}, name: req.body?.name || null,
});
-- res.status(201).json({ device_id: result.device.id, credential: result.credential, publish_key: result.publish_key, ...(result.whip_url ? { whip_url: result.whip_url } : {}), robot_id: json(result.device.robot_ids, [])[0], profile: result.profile });
++ res.status(201).json({ device_id: result.device.id, credential: result.credential, ...domain.present.video(result), robot_id: json(result.device.robot_ids, [])[0], profile: result.profile });
}));
// ── Bootstrap of a Network-paired machine (its node token, once after pairing) ────────────────
@@@ -249,7 +251,7 @@ function v1Router({ domain, apiAuth, limits, hub }) {
const device = await domain.devices.bindNode(req.principal.principal);
const issued = await domain.devices.issuePublishKey(device.id);
res.setHeader('Cache-Control', 'no-store');
-- res.status(201).json({ device_id: issued.device.id, publish_key: issued.publish_key, ...(issued.whip_url ? { whip_url: issued.whip_url } : {}), robot_id: json(issued.device.robot_ids, [])[0], profile: issued.profile });
++ res.status(201).json({ device_id: issued.device.id, ...domain.present.video(issued), robot_id: json(issued.device.robot_ids, [])[0], profile: issued.profile });
}));
return r;
ddiff --git a/server/app.js b/server/app.js
iindex a15904b..99c327f 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -9,7 +9,7 @@
* /auth/* Network SSO session for people (the control WS accepts the ov_token cookie)
* GET / a one-line text placeholder; the panel and the site are designed separately
*
-- * createApp({ config, db, valkey, registry, keys, hub, outbox, nodes, now, fetchImpl, log }) — everything
++ * createApp({ config, db, valkey, registry, keys, hub, outbox, nodes, openre, now, fetchImpl, log }) — everything
* injectable. `db` is an openvibe-sdk/db handle with the schema migrated; `hub` the realtime WebSocket
* hub (server/realtime.js), whose handleUpgrade the caller attaches to the HTTP server.
*/
@@@ -21,6 +21,7 @@ const { isLoopbackDirect } = require('openvibe-shared/metrics');
const { loadConfig } = require('./config');
const { openDb } = require('./db');
const { createKeyProvider, createUserAuth, createNodePrincipals } = require('./network');
++const { createOpenRe } = require('./openre/client');
const { createBotOutbox } = require('./events/outbox');
const { createDomain } = require('./domain');
const { createRealtime } = require('./realtime');
@@@ -46,7 +47,8 @@ function createApp(opts = {}) {
const outbox = opts.outbox || createBotOutbox({ db, config, fetchImpl: opts.eventsFetch, now, log });
const hub = opts.hub || createRealtime({ config, keys, userAuth, log, now });
const nodes = opts.nodes || createNodePrincipals(config, { fetchImpl });
-- const domain = createDomain({ db, config, outbox, link: hub, nodes, now, log });
++ 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);
const apiAuth = createApiAuth({ config, keys, userAuth });
const release = require('openvibe-shared/release').createRelease({ service: 'bot', root: path.join(__dirname, '..') });
ddiff --git a/server/config.js b/server/config.js
iindex 7437d99..7a383d9 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -129,6 +129,15 @@ function loadConfig(env = process.env) {
media: {
whipBase: trim(env.BOT_WHIP_BASE || ''),
},
++ // OpenRe.Stream (T15 R5): the publish key is the ingest key of the robot's OpenRe stream, the only kind
++ // OpenRe's WHIP worker admits. token is a Network service token holding openre.stream.read (the lookup
++ // by external ref), openre.stream.write and openre.key.rotate; it is never logged or returned. Either unset: devices pair without video (no
++ // publish_key, `video: "not_configured"`) and Bot mints no key of its own.
++ openre: {
++ url: trim(env.BOT_OPENRE_URL || ''),
++ token: String(env.BOT_OPENRE_TOKEN || '').trim(),
++ timeoutMs: Math.max(100, int(env.BOT_OPENRE_TIMEOUT_MS, 8000)),
++ },
// The one-line installer the owner copies next to the pairing code. scriptUrl is what the command
// prints; sourceUrl is where GET /install sends the client (302). Bot keeps no copy of the script:
// it redirects to OpenVibe.Node's canonical one, and only this config (never a query parameter)
ddiff --git a/server/domain/index.js b/server/domain/index.js
iindex b0c762a..01031cb 100644
---- a/server/domain/index.js
++++ b/server/domain/index.js
@@@ -64,7 +64,7 @@ function noteToHz(note) {
return 440 * 2 ** ((12 * (Number(m[3]) + 1) + semis - 69) / 12);
}
--function createDomain({ db, config, outbox, link = null, nodes = null, now = () => Date.now(), log = console }) {
++function createDomain({ db, config, outbox, link = null, nodes = null, openre = null, now = () => Date.now(), log = console }) {
// ── Presenters ────────────────────────────────────────────────────────────────────────────────
const presentRobot = (r) => (r ? {
id: r.id, name: r.name, profile_id: r.profile_id, profile_version: r.profile_version,
@@@ -80,6 +80,11 @@ function createDomain({ db, config, outbox, link = null, nodes = null, now = ()
created_at: iso(new Date(d.created_at).getTime()), updated_at: iso(new Date(d.updated_at).getTime()),
} : null);
++ /** A pairing/rotation answer's video fields: the publish key and its WHIP URL (once), or why there is no key. */
++ const presentVideo = (r) => (r.publish_key
++ ? { publish_key: r.publish_key, ...(r.whip_url ? { whip_url: r.whip_url } : {}) }
++ : { video: r.video || 'not_configured' });
++
async function emit(t, event_type, subject, payload) {
await outbox.emitIn(t, { ...ENVELOPE, event_type, subject, payload });
}
@@@ -157,7 +162,21 @@ function createDomain({ db, config, outbox, link = null, nodes = null, now = ()
await db.query(`UPDATE robots SET ${sets.join(', ')} WHERE id = $1`, args);
return getRobot(id);
}
++ /**
++ * Remove a robot. Its OpenRe stream's key is revoked first (rotated with no grace, its sessions ended), so
++ * nothing publishes as the robot afterwards; an OpenRe that refuses or does not answer leaves the robot in
++ * place for the owner to retry. The stream is archived when nothing was live (OpenRe refuses a live one).
++ */
async function removeRobot(id) {
++ const robot = await getRobot(id);
++ if (robot && robot.openre_stream_id && !openre) log.warn(`[Bot] robot ${id} removed with OpenRe stream ${robot.openre_stream_id} left as it is: OpenRe is not configured`);
++ if (robot && robot.openre_stream_id && openre) {
++ const ended = await openre.rotate(robot.openre_stream_id, robot.owner_subject, { grace_seconds: 0, end_sessions: true });
++ if (ended && !ended.sessions_ending) {
++ await openre.archive(robot.openre_stream_id, robot.owner_subject)
++ .catch((e) => log.warn(`[Bot] OpenRe stream ${robot.openre_stream_id} of removed robot ${id} not archived: ${e.message}`));
++ }
++ }
await db.query('DELETE FROM robots WHERE id = $1', [id]);
}
@@@ -203,7 +222,9 @@ function createDomain({ db, config, outbox, link = null, nodes = null, now = ()
* Redeem a pairing code. `robot` (from the installer command/QR) attributes a wrong code to that
* robot's live code and counts the try (5 end it); without it the code is matched by hash across
* every live code. On success the credential and the publish key are returned once, never stored in
-- * the clear.
++ * the clear. The publish key is a new ingest key of the robot's OpenRe stream (newStreamKey), asked for
++ * only once the code is good: an OpenRe that refuses or does not answer fails the pairing and leaves the
++ * code unused, with no device. Without OpenRe configured the device pairs without video.
*/
async function redeem({ robot = null, code, agent_version = null, device_kind = 'onboard', drivers = [], capabilities = {}, name = null }) {
if (networkPairing()) fail(410, 'bot.pairing_moved', `pairing moved to OpenVibe.Network: pair this machine on ${config.network.url}`);
@@@ -231,24 +252,73 @@ function createDomain({ db, config, outbox, link = null, nodes = null, now = ()
if (row.used_at) return { error: [403, 'bot.pairing_code_used', 'that pairing code has already been used'] };
if (new Date(row.expires_at).getTime() <= now()) return { error: [403, 'bot.pairing_code_expired', 'that pairing code has expired'] };
if (row.tries >= config.pairing.maxTries) return { error: [403, 'bot.pairing_code_locked', 'too many wrong tries; the code is dead'] };
++ const deviceDisplayName = text(name, 'name', 80);
++ const issued = await newStreamKey(await t.maybe('SELECT * FROM robots WHERE id = $1', [row.robot_id]));
await t.query('UPDATE pairing_codes SET used_at = $2 WHERE id = $1', [row.id, at]);
const deviceId = prefixedId('dev', now());
const credential = token(32);
-- const publishKey = token(32);
++ if (issued) await keepStream(t, row.robot_id, issued.streamId, deviceId);
const device = await t.maybe(
-- `INSERT INTO devices (id, robot_ids, name, kind, agent_version, drivers, capabilities, credential_hash, publish_key_hash, created_at, updated_at)
++ `INSERT INTO devices (id, robot_ids, name, kind, agent_version, drivers, capabilities, credential_hash, publish_key_hint, created_at, updated_at)
VALUES ($1, $2::jsonb, $3, $4, $5, $6::jsonb, $7::jsonb, $8, $9, $10, $10) RETURNING *`,
-- [deviceId, JSON.stringify([row.robot_id]), text(name, 'name', 80), device_kind, storable(agent_version || ''), JSON.stringify(drivers || []), JSON.stringify(capabilities || {}), hashSecret(credential), hashSecret(publishKey), at]);
-- return { device, credential, publishKey, robot_id: row.robot_id };
++ [deviceId, JSON.stringify([row.robot_id]), deviceDisplayName, device_kind, storable(agent_version || ''), JSON.stringify(drivers || []), JSON.stringify(capabilities || {}), hashSecret(credential), issued ? issued.hint : null, at]);
++ return { device, credential, issued, robot_id: row.robot_id };
});
if (result.error) fail(result.error[0], result.error[1], result.error[2]);
const profile = await getProfile(db, (await getRobot(result.robot_id)).profile_id);
-- const whUrl = whipUrl(result.publishKey);
-- return { device: result.device, credential: result.credential, publish_key: result.publishKey, ...(whUrl ? { whip_url: whUrl } : {}), profile: profile ? profile.profile : null };
++ return { device: result.device, credential: result.credential, ...videoKey(result.issued), profile: profile ? profile.profile : null };
++ }
++ // ── Video: the robot's OpenRe stream (T15 R5) ─────────────────────────────────────────────────
++ // OpenRe's WHIP worker admits only its own ingest keys, so the publish key is always one OpenRe issued for
++ // the robot's stream (external ref bot:robot:<id>); Bot keeps the stream id and the key's hint, never the
++ // key. One stream per robot, so the device given the newest key is the robot's publisher.
++ /**
++ * A new ingest key for the robot's OpenRe stream → { streamId, key, hint }, or null when OpenRe is not
++ * configured. The stored stream is rotated (graceSeconds: how long the previous key keeps publishing);
++ * with none stored, or one OpenRe no longer has, the stream is found by its external ref and rotated, or
++ * else created with its first key. Throws OpenRe's refusal or silence (502/503) and writes nothing here.
++ */
++ async function newStreamKey(robot, graceSeconds = 0) {
++ if (!openre || !robot) return null;
++ const owner = robot.owner_subject;
++ const rotated = async (streamId) => {
++ const r = await openre.rotate(streamId, owner, { grace_seconds: graceSeconds, end_sessions: false });
++ return r && { streamId, key: r.key };
++ };
++ let out = robot.openre_stream_id ? await rotated(robot.openre_stream_id) : null;
++ if (!out) {
++ const found = await openre.find(`bot:robot:${robot.id}`, owner);
++ if (found && found.id) out = await rotated(found.id);
++ }
++ if (!out) {
++ const created = await openre.create({
++ title: robot.name, protocols: ['webrtc'], recording_mode: 'none', recording_visibility: 'unlisted', playback_visibility: 'unlisted',
++ external_refs: [{ service: 'bot', type: 'robot', id: robot.id, label: robot.name }],
++ }, owner);
++ out = created && created.stream && { streamId: created.stream.id, key: created.key };
++ }
++ if (!out || typeof out.streamId !== 'string' || !out.key || typeof out.key.key !== 'string' || !out.key.key) {
++ fail(503, 'bot.openre_unavailable', 'OpenRe answered no ingest key for the robot');
++ }
++ return { streamId: out.streamId, key: out.key.key, hint: typeof out.key.hint === 'string' ? out.key.hint.slice(0, 16) : null };
++ }
++ /** Record the robot's stream and that `deviceId` now holds its key: other devices' keys are retired. */
++ async function keepStream(t, robotId, streamId, deviceId) {
++ await t.query('UPDATE robots SET openre_stream_id = $2 WHERE id = $1 AND openre_stream_id IS DISTINCT FROM $2', [robotId, streamId]);
++ await t.query('UPDATE devices SET publish_key_hint = NULL WHERE robot_ids @> $1::jsonb AND id <> $2 AND publish_key_hint IS NOT NULL',
++ [JSON.stringify([robotId]), deviceId]);
++ }
++ /** The answer fields for an issued key: publish_key and whip_url, or video: 'not_configured' without OpenRe. */
++ function videoKey(issued) {
++ if (!issued) return { video: 'not_configured' };
++ const whUrl = whipUrl(issued.key);
++ return { publish_key: issued.key, ...(whUrl ? { whip_url: whUrl } : {}) };
}
/**
* Where the device publishes its camera: OpenRe's WHIP ingest (`POST <base>/<key>`, RFC 9725) with
-- * this device's publish key as the stream key. It carries the key, so it is shown once, with the key.
++ * this device's publish key as the stream key. The key must be one OpenRe admits, an ingest key OpenRe
++ * issued for the robot's stream (newStreamKey): OpenRe refuses any other. It carries the key, so it is
++ * shown once, with the key.
* The base's trailing slashes are trimmed; with no base (BOT_WHIP_BASE unset or empty) it is null, and
* the caller omits the field entirely rather than sending null or an empty string.
*/
@@@ -273,18 +343,46 @@ function createDomain({ db, config, outbox, link = null, nodes = null, now = ()
async function listDevicesForRobot(robotId) {
return db.many('SELECT * FROM devices WHERE robot_ids @> $1::jsonb ORDER BY created_at DESC', [JSON.stringify([robotId])]);
}
++ /**
++ * A new credential and a new publish key (the robot's OpenRe stream rotated; the old key keeps publishing
++ * for the credential's grace). OpenRe is asked first: if it refuses or does not answer, nothing changes.
++ */
async function rotateDevice(id) {
const d = await getDevice(id);
if (!d || d.revoked_at) fail(404, 'bot.device_not_found', 'no live device');
++ const robotId = json(d.robot_ids, [])[0];
++ const issued = await newStreamKey(await getRobot(robotId), Math.ceil(config.device.rotateGraceMs / 1000));
const credential = token(32);
-- const publishKey = token(32);
const at = iso(now());
-- const updated = await db.maybe(
-- `UPDATE devices SET credential_hash = $2, credential_prev_hash = $3, prev_valid_until = $4, publish_key_hash = $5, updated_at = $6
-- WHERE id = $1 RETURNING *`,
-- [id, hashSecret(credential), d.credential_hash, iso(now() + config.device.rotateGraceMs), hashSecret(publishKey), at]);
-- const whUrl = whipUrl(publishKey);
-- return { device: updated, credential, publish_key: publishKey, ...(whUrl ? { whip_url: whUrl } : {}) };
++ const updated = await db.tx(async (t) => {
++ if (issued) await keepStream(t, robotId, issued.streamId, id);
++ return t.maybe(
++ `UPDATE devices SET credential_hash = $2, credential_prev_hash = $3, prev_valid_until = $4,
++ publish_key_hint = CASE WHEN $5 THEN $6 ELSE publish_key_hint END, updated_at = $7
++ WHERE id = $1 RETURNING *`,
++ [id, hashSecret(credential), d.credential_hash, iso(now() + config.device.rotateGraceMs), !!issued, issued ? issued.hint : null, at]);
++ });
++ return { device: updated, credential, ...videoKey(issued) };
++ }
++ /**
++ * Revoke the OpenRe key a device holds, after the device itself is revoked: the robot's stream is rotated
++ * with no grace and its sessions ended (OpenRe refuses to archive a live stream). The new key is shown to
++ * no one; the robot's next pairing or rotation issues another. A failure throws and keeps the device's
++ * key hint, so the owner's retry asks again. → true when a key was revoked.
++ */
++ async function revokeVideo(id) {
++ const d = await getDevice(id);
++ if (!d || !d.publish_key_hint) return false;
++ const robot = await getRobot(json(d.robot_ids, [])[0]);
++ if (robot && robot.openre_stream_id) {
++ if (!openre) {
++ log.warn(`[Bot] device ${id} revoked but its key on OpenRe stream ${robot.openre_stream_id} was not: OpenRe is not configured`);
++ return false;
++ }
++ await openre.rotate(robot.openre_stream_id, robot.owner_subject, { grace_seconds: 0, end_sessions: true });
++ }
++ await db.query('UPDATE devices SET publish_key_hint = NULL WHERE id = $1', [id]);
++ return true;
}
async function revokeDevice(id) {
const d = await getDevice(id);
@@@ -327,18 +425,25 @@ function createDomain({ db, config, outbox, link = null, nodes = null, now = ()
return inserted || db.maybe('SELECT * FROM devices WHERE node_principal = $1 AND revoked_at IS NULL', [principalId]);
}
/**
-- * Issue (or re-issue) a device's WHIP publish key: the new key replaces the old one, which stops working.
-- * → { device, publish_key, whip_url?, profile }, the key shown this once.
++ * Issue (or re-issue) a device's WHIP publish key, a new ingest key of its robot's OpenRe stream: the new
++ * key replaces the old one, which stops working. → { device, publish_key, whip_url?, profile }, the key
++ * shown this once; without OpenRe configured { device, video: 'not_configured', profile }.
*/
async function issuePublishKey(id) {
-- const publishKey = token(32);
-- const device = await db.maybe('UPDATE devices SET publish_key_hash = $2, updated_at = $3 WHERE id = $1 AND revoked_at IS NULL RETURNING *',
-- [id, hashSecret(publishKey), iso(now())]);
++ const live = await db.maybe('SELECT * FROM devices WHERE id = $1 AND revoked_at IS NULL', [id]);
++ if (!live) fail(404, 'bot.device_not_found', 'no live device');
++ const robot = await getRobot(json(live.robot_ids, [])[0]);
++ const issued = await newStreamKey(robot);
++ const device = await db.tx(async (t) => {
++ if (issued) await keepStream(t, robot.id, issued.streamId, id);
++ return t.maybe(
++ `UPDATE devices SET publish_key_hint = CASE WHEN $2 THEN $3 ELSE publish_key_hint END, updated_at = $4
++ WHERE id = $1 AND revoked_at IS NULL RETURNING *`,
++ [id, !!issued, issued ? issued.hint : null, iso(now())]);
++ });
if (!device) fail(404, 'bot.device_not_found', 'no live device');
-- const robot = await getRobot(json(device.robot_ids, [])[0]);
const profile = robot ? await getProfile(db, robot.profile_id) : null;
-- const whUrl = whipUrl(publishKey);
-- return { device, publish_key: publishKey, ...(whUrl ? { whip_url: whUrl } : {}), profile: profile ? profile.profile : null };
++ return { device, ...videoKey(issued), profile: profile ? profile.profile : null };
}
/** Persist what a Network-paired device declares (kind, drivers, capabilities, agent_version): only the given fields. */
async function updateDeclared(id, { kind, drivers, capabilities, agent_version: agentVersion }) {
@@@ -678,11 +783,11 @@ function createDomain({ db, config, outbox, link = null, nodes = null, now = ()
return {
db, now, config, log, outbox, link,
-- present: { robot: presentRobot, device: presentDevice },
++ present: { robot: presentRobot, device: presentDevice, video: presentVideo },
robots: { create: createRobot, list: listRobots, get: getRobot, update: updateRobot, remove: removeRobot },
members: { roleOf, add: addOperator, remove: removeOperator, list: listOperators },
pairing: { create: createPairingCode, redeem, prune: prunePairingCodes, installerCommand, driverForProfile, whipUrl },
-- devices: { byCredential, bindNode, issuePublishKey, updateDeclared, revokeNode, get: getDevice, listForRobot: listDevicesForRobot, rotate: rotateDevice, revoke: revokeDevice, touchSeen, setOnline },
++ devices: { byCredential, bindNode, issuePublishKey, revokeVideo, updateDeclared, revokeNode, get: getDevice, listForRobot: listDevicesForRobot, rotate: rotateDevice, revoke: revokeDevice, touchSeen, setOnline },
estop: { set: setEstop, clear: clearEstop },
queue: { join: joinQueue, state: queueState, currentTurn, consume: consumeTurn, sweep: sweepQueues },
audit: { record: auditCommand, list: listAudit, listPage: listAuditPage, prune: pruneAudit },
ddiff --git a/server/openre/client.js b/server/openre/client.js
nnew file mode 100644
iindex 0000000..1229ca4
---- /dev/null
++++ b/server/openre/client.js
@@@ -0,0 +1,69 @@
++'use strict';
++
++/**
++ * OpenRe.Stream client: the robot's video stream and its WHIP ingest key (T15 R5).
++ *
++ * OpenRe's WHIP worker admits only keys in its own store (resolveIngestKey), so a device's publish key is
++ * always an OpenRe ingest key: Bot creates one stream per robot (external ref bot:robot:<id>) and hands the
++ * device the key OpenRe returns, shown once and never stored here. Calls carry BOT_OPENRE_TOKEN, a Network
++ * service token holding openre.stream.read (find), openre.stream.write (create, archive) and openre.key.rotate
++ * (rotate), and act for the robot's owner
++ * (X-OV-Subject), so OpenRe limits them to that owner's streams.
++ *
++ * find(ref, owner) GET /api/v1/streams?external_ref=… → the stream, or null
++ * create(body, owner) POST /api/v1/streams → { stream, key: { id, key, hint } }
++ * rotate(id, owner, { grace_seconds, end_sessions })
++ * POST /api/v1/streams/:id/keys/rotate → { key: { id, key, hint }, … }
++ * archive(id, owner) DELETE /api/v1/streams/:id (OpenRe refuses a live stream: 409)
++ *
++ * A refusal (4xx) throws 502 bot.openre_refused with OpenRe's problem code and detail; no answer, a timeout
++ * or a 5xx throws 503 bot.openre_unavailable. A 404 for a named stream (rotate, archive) answers null. The token is
++ * never logged, returned or put in an error. Unset BOT_OPENRE_URL or BOT_OPENRE_TOKEN: createOpenRe → null.
++ */
++const { BotError } = require('../util');
++
++function createOpenRe(config, { fetchImpl = globalThis.fetch } = {}) {
++ const { url, token, timeoutMs } = config.openre || {};
++ if (!url || !token) return null;
++
++ async function call(method, path, { owner, body, named = false } = {}) {
++ let res;
++ try {
++ res = await fetchImpl(`${url}${path}`, {
++ method,
++ headers: {
++ Authorization: `Bearer ${token}`, Accept: 'application/json', 'X-OV-Subject': owner,
++ ...(body ? { 'Content-Type': 'application/json' } : {}),
++ },
++ ...(body ? { body: JSON.stringify(body) } : {}),
++ signal: AbortSignal.timeout(timeoutMs),
++ });
++ } catch (e) {
++ const why = e && (e.name === 'TimeoutError' || e.name === 'AbortError') ? `no answer within ${timeoutMs} ms` : 'unreachable';
++ throw new BotError(503, 'bot.openre_unavailable', `OpenRe did not answer for the robot's video: ${why}`);
++ }
++ if (res.status === 204) return {};
++ const out = await res.json().catch(() => null);
++ if (res.ok && out) return out;
++ if (res.status === 404 && named) return null;
++ const code = out && typeof out.code === 'string' ? out.code : null;
++ const detail = out && typeof out.detail === 'string' ? `: ${out.detail.slice(0, 200)}` : '';
++ if (res.status >= 400 && res.status < 500) {
++ throw new BotError(502, 'bot.openre_refused', `OpenRe refused the robot's video (${res.status}${code ? ` ${code}` : ''})${detail}`, { openre: { status: res.status, code } });
++ }
++ throw new BotError(503, 'bot.openre_unavailable', `OpenRe answered ${res.status} for the robot's video`);
++ }
++ const stream = (id) => `/api/v1/streams/${encodeURIComponent(id)}`;
++ return {
++ async find(ref, owner) {
++ const out = await call('GET', `/api/v1/streams?external_ref=${encodeURIComponent(ref)}`, { owner });
++ return (out && Array.isArray(out.streams) && out.streams[0]) || null;
++ },
++ create: (body, owner) => call('POST', '/api/v1/streams', { owner, body }),
++ rotate: (id, owner, { grace_seconds = 0, end_sessions = false } = {}) =>
++ call('POST', `${stream(id)}/keys/rotate`, { owner, body: { grace_seconds, end_sessions }, named: true }),
++ archive: (id, owner) => call('DELETE', stream(id), { owner, named: true }),
++ };
++}
++
++module.exports = { createOpenRe };
ddiff --git a/server/realtime.js b/server/realtime.js
iindex 88ab219..87597c3 100644
---- a/server/realtime.js
++++ b/server/realtime.js
@@@ -250,7 +250,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
});
attachDevice(conn, r.device);
sendFrame(conn, 'paired', {
-- device_id: r.device.id, credential: r.credential, publish_key: r.publish_key, ...(r.whip_url ? { whip_url: r.whip_url } : {}),
++ device_id: r.device.id, credential: r.credential, ...domain.present.video(r),
robot_ids: deviceRobotIds(r.device), profile_id: r.profile ? r.profile.id : null, profile: r.profile,
});
sendHello(conn);
ddiff --git a/test/helpers/app.js b/test/helpers/app.js
iindex caba504..0160402 100644
---- a/test/helpers/app.js
++++ b/test/helpers/app.js
@@@ -9,6 +9,7 @@
* otherwise a service token with `cap`
* t.ws(path, { headers }) a WebSocket client: .send, .waitFor
* t.clock.offset advance the injected clock (ms)
++ * t.openre the OpenRe stub (null with opts.openre false)
*/
const fs = require('fs');
const os = require('os');
@@@ -16,12 +17,14 @@ const path = require('path');
const http = require('http');
const crypto = require('crypto');
const WebSocket = require('ws');
--const { startNetwork, startEvents } = require('./stubs');
++const { startNetwork, startEvents, startOpenRe } = require('./stubs');
const { testDb } = require('./db');
async function boot(opts = {}) {
const network = await startNetwork();
const events = await startEvents();
++ // OpenRe issues every publish key; `openre: false` boots with BOT_OPENRE_* unset (pairing without video).
++ const openre = opts.openre === false ? null : await startOpenRe();
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bot-test-'));
const env = {
NODE_ENV: 'test',
@@@ -39,6 +42,7 @@ async function boot(opts = {}) {
BOT_OFFLINE_GRACE_MS: '0',
BOT_ROTATE_GRACE_MS: '60000',
BOT_QUEUE_TURN_MS: '60000',
++ ...(openre ? { BOT_OPENRE_URL: openre.url, BOT_OPENRE_TOKEN: openre.token, BOT_OPENRE_TIMEOUT_MS: '1500' } : {}),
...(opts.env || {}),
};
for (const k of Object.keys(require.cache)) if (k.includes(`${path.sep}server${path.sep}`)) delete require.cache[k];
@@@ -120,14 +124,14 @@ async function boot(opts = {}) {
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
return {
-- app, base, call, ws, robot, wait, domain, hub, db: domain.db, store, config, clock, network, events, logs, dir,
++ app, base, call, ws, robot, wait, domain, hub, db: domain.db, store, config, clock, network, events, openre, logs, dir,
outboxRows: async (type) => (await domain.db.many('SELECT envelope FROM bot_event_outbox ORDER BY id')).map((r) => r.envelope).filter((e) => !type || e.event_type === type),
close: async () => {
await hub.close().catch(() => {});
server.closeAllConnections();
await new Promise((r) => server.close(r));
await app.locals.outbox.stop();
-- await Promise.all([network.close(), events.close()]);
++ await Promise.all([network.close(), events.close(), openre && openre.close()]);
await store.close();
fs.rmSync(dir, { recursive: true, force: true });
},
ddiff --git a/test/helpers/stubs.js b/test/helpers/stubs.js
iindex 284b966..18d13d8 100644
---- a/test/helpers/stubs.js
++++ b/test/helpers/stubs.js
@@@ -6,6 +6,11 @@
* 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
* 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=,
++ * POST /api/v1/streams, POST …/:id/keys/rotate, DELETE …/:id (409 while live), behind one bearer
++ * token whose capabilities OpenRe's guards check (setCaps(list) to narrow them; 403
++ * capability.denied). streams, calls (every request, its token and X-OV-Subject), admits(key)
++ * (OpenRe's resolveIngestKey: an active key or one in grace), failNext(status | 'hang')
*/
const http = require('http');
const crypto = require('crypto');
@@@ -128,4 +133,87 @@ async function startEvents() {
return { url, events, close: () => new Promise((r) => server.close(r)) };
}
--module.exports = { startNetwork, startEvents };
++// The capabilities BOT_OPENRE_TOKEN must hold: GET /streams is openre.stream.read, POST and DELETE
++// openre.stream.write, keys/rotate openre.key.rotate (OpenRe server/api/v1.js guards).
++const OPENRE_CAPS = ['openre.stream.read', 'openre.stream.write', 'openre.key.rotate'];
++
++async function startOpenRe({ token = `ovt_${crypto.randomBytes(16).toString('hex')}`, caps = OPENRE_CAPS } = {}) {
++ let granted = [...caps];
++ // OpenRe's hasCap: the exact capability or a `.*` grant covering it.
++ const hasCap = (id) => granted.some((g) => g === id || (g.endsWith('.*') && id.startsWith(g.slice(0, -1))));
++ const streams = new Map();
++ const calls = [];
++ let failure = null;
++ let n = 0;
++ const newKey = () => {
++ const key = `ork_${crypto.randomBytes(32).toString('base64url')}`;
++ return { id: `key_${++n}`, key, hint: key.slice(-4), status: 'active', grace_until: null };
++ };
++ const view = (st) => ({ id: st.id, owner: { type: 'user', id: st.owner }, protocols: st.protocols, state: st.state, external_refs: st.refs,
++ keys: st.keys.filter((k) => k.status !== 'revoked').map((k) => ({ id: k.id, hint: k.hint, status: k.status })) });
++ const problem = (res, status, code, detail) => { res.writeHead(status, { 'Content-Type': 'application/problem+json' }); res.end(JSON.stringify({ type: 'about:blank', status, code, detail })); };
++ function admits(key) {
++ for (const st of streams.values()) {
++ const k = st.keys.find((x) => x.key === key);
++ if (k) return st.state === 'active' && (k.status === 'active' || (k.status === 'grace' && k.grace_until > Date.now()));
++ }
++ return false;
++ }
++ const server = http.createServer(async (req, res) => {
++ const raw = await readBody(req);
++ let body = null; try { body = raw ? JSON.parse(raw) : null; } catch { body = null; }
++ const subject = req.headers['x-ov-subject'] || null;
++ const url = new URL(req.url, 'http://openre.test');
++ calls.push({ method: req.method, path: url.pathname, query: url.search, body, subject, authorization: req.headers.authorization || null });
++ if (failure === 'hang') return; // never answered: the client's timeout ends it
++ if (failure) { const status = failure; failure = null; return problem(res, status, status >= 500 ? 'openre.internal' : 'openre.forbidden', `stubbed ${status}`); }
++ if (req.headers.authorization !== `Bearer ${token}`) return problem(res, 401, 'token.invalid', 'a service token is required');
++ const m = /^\/api\/v1\/streams(?:\/([^/]+)(\/keys\/rotate)?)?$/.exec(url.pathname);
++ const need = m && (m[2] ? 'openre.key.rotate' : req.method === 'GET' ? 'openre.stream.read' : 'openre.stream.write');
++ if (need && !hasCap(need)) return problem(res, 403, 'capability.denied', `${need} not granted`);
++ if (!subject || !/^usr_/.test(subject)) return problem(res, 400, 'subject.invalid', 'X-OV-Subject must be a usr_… subject id');
++ const mine = (id) => { const st = streams.get(id); return st && st.state !== 'archived' && st.owner === subject ? st : null; };
++ if (url.pathname === '/api/v1/streams' && req.method === 'GET') {
++ const [service, type, ...rest] = String(url.searchParams.get('external_ref') || '').split(':');
++ const st = [...streams.values()].find((x) => x.state !== 'archived' && x.owner === subject && x.refs.some((r) => r.service === service && r.type === type && r.id === rest.join(':')));
++ return send(res, 200, { streams: st ? [view(st)] : [] });
++ }
++ if (url.pathname === '/api/v1/streams' && req.method === 'POST') {
++ const refs = (body && body.external_refs) || [];
++ if (refs.some((r) => [...streams.values()].some((x) => x.state !== 'archived' && x.refs.some((y) => y.service === r.service && y.type === r.type && y.id === r.id)))) {
++ return problem(res, 409, 'openre.ref_taken', 'already belongs to another stream definition');
++ }
++ const st = { id: `str_${++n}`, owner: subject, protocols: body.protocols, refs, state: 'active', live: false, keys: [newKey()] };
++ streams.set(st.id, st);
++ const k = st.keys[0];
++ return send(res, 201, { stream: view(st), key: { id: k.id, key: k.key, hint: k.hint, shown_once: true } });
++ }
++ const st = m && m[1] && mine(decodeURIComponent(m[1]));
++ if (m && m[1] && !st) return problem(res, 404, 'openre.stream_not_found', 'no such stream definition');
++ if (st && m[2] && req.method === 'POST') {
++ const grace = Math.max(0, Number(body && body.grace_seconds) || 0);
++ const old = st.keys.filter((k) => k.status === 'active');
++ for (const k of old) Object.assign(k, grace ? { status: 'grace', grace_until: Date.now() + grace * 1000 } : { status: 'revoked' });
++ const k = newKey();
++ st.keys.push(k);
++ const ending = body && body.end_sessions && st.live ? 1 : 0;
++ return send(res, 200, { key: { id: k.id, key: k.key, hint: k.hint, shown_once: true }, retired: old.map((x) => ({ id: x.id, hint: x.hint, status: x.status })), grace_until: null, sessions_ending: ending });
++ }
++ if (st && !m[2] && req.method === 'DELETE') {
++ if (st.live) return problem(res, 409, 'openre.stream_live', 'end the live session before archiving this stream');
++ st.state = 'archived';
++ for (const k of st.keys) k.status = 'revoked';
++ res.writeHead(204); return res.end();
++ }
++ problem(res, 404, 'not_found', 'not found');
++ });
++ const url = await listen(server);
++ return {
++ url, token, streams, calls, admits,
++ failNext: (what) => { failure = what; },
++ setCaps: (list) => { granted = [...(list || caps)]; },
++ close: () => { server.closeAllConnections(); return new Promise((r) => server.close(r)); },
++ };
++}
++
++module.exports = { startNetwork, startEvents, startOpenRe };
ddiff --git a/test/node-principal.test.js b/test/node-principal.test.js
iindex f158395..3339569 100644
---- a/test/node-principal.test.js
++++ b/test/node-principal.test.js
@@@ -4,11 +4,9 @@
// as for a credential, `status` persists what the machine declares, `reauth` keeps the socket, and revoke
// and rotate reach Network and the machine. Credential devices keep today's behaviour.
const assert = require('assert');
--const crypto = require('crypto');
const { boot, check, done } = require('./helpers/app');
const WHIP_BASE = 'https://whip.test/ingest';
--const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');
(async () => {
const t = await boot({ env: { BOT_WHIP_BASE: WHIP_BASE } });
@@@ -44,7 +42,9 @@ const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex'
assert.strictEqual(d.agent_version, null);
assert.strictEqual(d.credential_hash, null);
assert.strictEqual(d.node_principal, principal);
-- assert.strictEqual(d.publish_key_hash, sha256(r.json.publish_key));
++ assert.strictEqual(d.publish_key_hint, r.json.publish_key.slice(-4), 'only the hint of the OpenRe key is kept');
++ assert.strictEqual(d.publish_key_hash, null, 'Bot mints and hashes no key of its own');
++ assert.ok(t.openre.admits(r.json.publish_key), 'the publish key is one OpenRe admits');
});
await check('bind twice answers the same device and a new publish key; the old key stops working', async () => {
@@@ -55,8 +55,9 @@ const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex'
assert.strictEqual(second.json.device_id, first.json.device_id);
assert.notStrictEqual(second.json.publish_key, first.json.publish_key);
const d = await deviceRow(first.json.device_id);
-- assert.strictEqual(d.publish_key_hash, sha256(second.json.publish_key));
-- assert.notStrictEqual(d.publish_key_hash, sha256(first.json.publish_key));
++ assert.strictEqual(d.publish_key_hint, second.json.publish_key.slice(-4));
++ assert.ok(t.openre.admits(second.json.publish_key), 'the new key is admitted');
++ assert.ok(!t.openre.admits(first.json.publish_key), 'the old key stops working');
assert.strictEqual(Number((await t.db.maybe('SELECT count(*) AS n FROM devices WHERE node_principal = $1', [principal])).n), 1);
});
@@@ -82,7 +83,7 @@ const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex'
assert.deepStrictEqual(hello.robot_ids, [robot.id]);
const d = await deviceRow(hello.device_id);
assert.strictEqual(d.node_principal, principal);
-- assert.strictEqual(d.publish_key_hash, null, 'no publish key until the machine calls bind');
++ assert.strictEqual(d.publish_key_hint, null, 'no publish key until the machine calls bind');
assert.ok(t.hub.isOnline(d.id));
// A later bind finds the same device.
const b = await bind(t.network.signNode(principal));
ddiff --git a/test/openre.test.js b/test/openre.test.js
nnew file mode 100644
iindex 0000000..22f7b5b
---- /dev/null
++++ b/test/openre.test.js
@@@ -0,0 +1,259 @@
++'use strict';
++// The publish key is an OpenRe ingest key (T15 R5): OpenRe's WHIP worker admits only keys in its own store, so
++// pairing creates the robot's OpenRe stream (external ref bot:robot:<id>) and hands the device the key OpenRe
++// returns; a re-pair and a credential rotation rotate that stream's key, a revocation rotates it with no grace
++// and ends the session, a robot's removal also archives the stream when nothing is live. OpenRe refusing or
++// not answering is a clean error with nothing half-written; Bot's token for OpenRe is never logged or
++// answered; with BOT_OPENRE_* unset devices pair without video and Bot mints no key of its own.
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/app');
++const { createOpenRe } = require('../server/openre/client');
++
++const WHIP_BASE = 'https://ingest.test/whip';
++
++(async () => {
++ const t = await boot({ env: { BOT_WHIP_BASE: WHIP_BASE } });
++ const alex = t.network.newUser('alex');
++ const responses = [];
++ const call = async (...a) => { const r = await t.call(...a); responses.push(r.text); return r; };
++ const redeem = (body) => call('POST', '/api/v1/pair', { token: null, body });
++ const robotRow = (id) => t.db.maybe('SELECT * FROM robots WHERE id = $1', [id]);
++ const deviceRow = (id) => t.db.maybe('SELECT * FROM devices WHERE id = $1', [id]);
++ const callsSince = (n) => t.openre.calls.slice(n);
++ const newCode = async (robotId) => (await call('POST', `/api/v1/robots/${robotId}/pairing-code`, { user: alex })).json;
++ async function pairedRobot() {
++ const { robot, pairing } = await t.robot(alex);
++ const paired = await redeem({ robot: robot.id, code: pairing.code });
++ assert.strictEqual(paired.status, 201, paired.text);
++ return { robot, paired: paired.json };
++ }
++
++ await check('the client is null without a URL and a token, and its errors never carry the token', async () => {
++ assert.strictEqual(createOpenRe({ openre: { url: '', token: 'x', timeoutMs: 100 } }), null);
++ assert.strictEqual(createOpenRe({ openre: { url: 'http://openre.test', token: '', timeoutMs: 100 } }), null);
++ const secret = 'ovt_never_shown_anywhere';
++ const down = createOpenRe({ openre: { url: 'http://openre.test', token: secret, timeoutMs: 100 } }, { fetchImpl: async () => { throw new Error(`connect ECONNREFUSED Bearer ${secret}`); } });
++ const e = await down.create({}, alex.subject).then(() => null, (x) => x);
++ assert.strictEqual(e.status, 503);
++ assert.strictEqual(e.code, 'bot.openre_unavailable');
++ assert.ok(!JSON.stringify({ m: e.message, d: e.detail, x: e.extra }).includes(secret));
++ });
++
++ await check('migration 0003 adds robots.openre_stream_id and devices.publish_key_hint', async () => {
++ const cols = await t.db.many(
++ `SELECT table_name, column_name FROM information_schema.columns
++ WHERE (table_name = 'robots' AND column_name = 'openre_stream_id') OR (table_name = 'devices' AND column_name = 'publish_key_hint')`);
++ assert.deepStrictEqual(cols.map((c) => `${c.table_name}.${c.column_name}`).sort(), ['devices.publish_key_hint', 'robots.openre_stream_id']);
++ });
++
++ await check('pairing creates the robot\'s OpenRe stream and hands the device the ingest key OpenRe issued', async () => {
++ const before = t.openre.calls.length;
++ const { robot, paired } = await pairedRobot();
++ const calls = callsSince(before);
++ assert.deepStrictEqual(calls.map((c) => `${c.method} ${c.path}`), ['GET /api/v1/streams', 'POST /api/v1/streams']);
++ assert.strictEqual(calls[0].query, `?external_ref=${encodeURIComponent(`bot:robot:${robot.id}`)}`);
++ const create = calls[1];
++ assert.strictEqual(create.subject, alex.subject, 'OpenRe is asked for the robot\'s owner');
++ assert.strictEqual(create.authorization, `Bearer ${t.openre.token}`);
++ assert.deepStrictEqual(create.body.protocols, ['webrtc']);
++ assert.deepStrictEqual(create.body.external_refs, [{ service: 'bot', type: 'robot', id: robot.id, label: robot.name }]);
++ assert.match(paired.publish_key, /^ork_[A-Za-z0-9_-]{43}$/);
++ assert.ok(t.openre.admits(paired.publish_key), 'OpenRe admits the publish key');
++ assert.strictEqual(paired.whip_url, `${WHIP_BASE}/${paired.publish_key}`);
++ assert.ok(!('video' in paired));
++ const stream = [...t.openre.streams.values()].find((s) => s.refs.some((r) => r.id === robot.id));
++ const r = await robotRow(robot.id);
++ assert.strictEqual(r.openre_stream_id, stream.id, 'only the stream id is stored');
++ const d = await deviceRow(paired.device_id);
++ assert.strictEqual(d.publish_key_hint, paired.publish_key.slice(-4));
++ assert.strictEqual(d.publish_key_hash, null, 'Bot mints and hashes no publish key');
++ assert.ok(!JSON.stringify([r, d]).includes(paired.publish_key), 'the key is not stored');
++ });
++
++ await check('re-pairing a robot rotates its stored stream instead of creating another', async () => {
++ const { robot, paired } = await pairedRobot();
++ const streamId = (await robotRow(robot.id)).openre_stream_id;
++ const before = t.openre.calls.length;
++ const again = await redeem({ robot: robot.id, code: (await newCode(robot.id)).code });
++ assert.strictEqual(again.status, 201, again.text);
++ const calls = callsSince(before).filter((c) => c.path.startsWith('/api/v1/streams'));
++ assert.deepStrictEqual(calls.map((c) => `${c.method} ${c.path}`), [`POST /api/v1/streams/${streamId}/keys/rotate`]);
++ assert.deepStrictEqual(calls[0].body, { grace_seconds: 0, end_sessions: false });
++ assert.ok(t.openre.admits(again.json.publish_key));
++ assert.ok(!t.openre.admits(paired.publish_key), 'the first device\'s key stops working');
++ assert.strictEqual((await robotRow(robot.id)).openre_stream_id, streamId);
++ assert.strictEqual((await deviceRow(paired.device_id)).publish_key_hint, null, 'the first device holds no key any more');
++ });
++
++ await check('with no stored stream id the stream is found by its external ref and rotated, never created twice', async () => {
++ const { robot } = await pairedRobot();
++ const streamId = (await robotRow(robot.id)).openre_stream_id;
++ await t.db.query('UPDATE robots SET openre_stream_id = NULL WHERE id = $1', [robot.id]);
++ const before = t.openre.calls.length;
++ const again = await redeem({ robot: robot.id, code: (await newCode(robot.id)).code });
++ assert.strictEqual(again.status, 201, again.text);
++ assert.deepStrictEqual(callsSince(before).filter((c) => c.path.startsWith('/api/v1/streams')).map((c) => `${c.method} ${c.path}`),
++ ['GET /api/v1/streams', `POST /api/v1/streams/${streamId}/keys/rotate`]);
++ assert.strictEqual((await robotRow(robot.id)).openre_stream_id, streamId);
++ assert.ok(t.openre.admits(again.json.publish_key));
++ });
++
++ await check('a credential rotation rotates the stream key with the credential\'s grace', async () => {
++ const { robot, paired } = await pairedRobot();
++ const streamId = (await robotRow(robot.id)).openre_stream_id;
++ const before = t.openre.calls.length;
++ const rot = await call('POST', `/api/v1/devices/${paired.device_id}/rotate`, { user: alex });
++ assert.strictEqual(rot.status, 200, rot.text);
++ const calls = callsSince(before);
++ assert.deepStrictEqual(calls.map((c) => `${c.method} ${c.path}`), [`POST /api/v1/streams/${streamId}/keys/rotate`]);
++ assert.deepStrictEqual(calls[0].body, { grace_seconds: 60, end_sessions: false });
++ assert.notStrictEqual(rot.json.publish_key, paired.publish_key);
++ assert.strictEqual(rot.json.whip_url, `${WHIP_BASE}/${rot.json.publish_key}`);
++ assert.ok(t.openre.admits(rot.json.publish_key), 'the new key is admitted');
++ assert.ok(t.openre.admits(paired.publish_key), 'the old key publishes through the grace');
++ assert.strictEqual((await deviceRow(paired.device_id)).publish_key_hint, rot.json.publish_key.slice(-4));
++ });
++
++ await check('revoking a device revokes its key at once and ends its live session', async () => {
++ const { robot, paired } = await pairedRobot();
++ const streamId = (await robotRow(robot.id)).openre_stream_id;
++ const before = t.openre.calls.length;
++ const r = await call('POST', `/api/v1/devices/${paired.device_id}/revoke`, { user: alex });
++ assert.strictEqual(r.status, 200, r.text);
++ const calls = callsSince(before);
++ assert.deepStrictEqual(calls.map((c) => `${c.method} ${c.path}`), [`POST /api/v1/streams/${streamId}/keys/rotate`]);
++ assert.deepStrictEqual(calls[0].body, { grace_seconds: 0, end_sessions: true });
++ assert.ok(!t.openre.admits(paired.publish_key));
++ assert.ok(!r.text.includes('ork_'), 'the throwaway key is answered to no one');
++ assert.strictEqual((await deviceRow(paired.device_id)).publish_key_hint, null);
++ });
++
++ await check('a revocation OpenRe does not answer still revokes the device; the owner\'s retry revokes the key', async () => {
++ const { robot, paired } = await pairedRobot();
++ t.openre.failNext(503);
++ const r = await call('POST', `/api/v1/devices/${paired.device_id}/revoke`, { user: alex });
++ assert.strictEqual(r.status, 503, r.text);
++ assert.strictEqual(r.json.code, 'bot.openre_unavailable');
++ const d = await deviceRow(paired.device_id);
++ assert.ok(d.revoked_at, 'the device is revoked regardless');
++ assert.ok(d.publish_key_hint, 'the key is still owed a revocation');
++ assert.ok(t.openre.admits(paired.publish_key));
++ const retry = await call('POST', `/api/v1/devices/${paired.device_id}/revoke`, { user: alex });
++ assert.strictEqual(retry.status, 200, retry.text);
++ assert.ok(!t.openre.admits(paired.publish_key));
++ assert.ok((await robotRow(robot.id)).openre_stream_id);
++ });
++
++ await check('removing a robot revokes its stream key and archives the stream when nothing is live', async () => {
++ const { robot, paired } = await pairedRobot();
++ const streamId = (await robotRow(robot.id)).openre_stream_id;
++ const before = t.openre.calls.length;
++ const r = await call('DELETE', `/api/v1/robots/${robot.id}`, { user: alex });
++ assert.strictEqual(r.status, 204, r.text);
++ const calls = callsSince(before);
++ assert.deepStrictEqual(calls.map((c) => `${c.method} ${c.path}`), [`POST /api/v1/streams/${streamId}/keys/rotate`, `DELETE /api/v1/streams/${streamId}`]);
++ assert.deepStrictEqual(calls[0].body, { grace_seconds: 0, end_sessions: true });
++ assert.strictEqual(t.openre.streams.get(streamId).state, 'archived');
++ assert.ok(!t.openre.admits(paired.publish_key));
++ assert.strictEqual(await robotRow(robot.id), null);
++ });
++
++ await check('removing a robot whose stream is live ends the session and leaves the archive to OpenRe\'s refusal', async () => {
++ const { robot, paired } = await pairedRobot();
++ const streamId = (await robotRow(robot.id)).openre_stream_id;
++ t.openre.streams.get(streamId).live = true;
++ const before = t.openre.calls.length;
++ const r = await call('DELETE', `/api/v1/robots/${robot.id}`, { user: alex });
++ assert.strictEqual(r.status, 204, r.text);
++ assert.deepStrictEqual(callsSince(before).map((c) => `${c.method} ${c.path}`), [`POST /api/v1/streams/${streamId}/keys/rotate`],
++ 'no DELETE while a session is ending');
++ assert.ok(!t.openre.admits(paired.publish_key));
++ assert.strictEqual(await robotRow(robot.id), null);
++ });
++
++ for (const [what, fault, status, code] of [
++ ['a 5xx', 500, 503, 'bot.openre_unavailable'],
++ ['a 4xx', 403, 502, 'bot.openre_refused'],
++ ['a timeout', 'hang', 503, 'bot.openre_unavailable'],
++ ]) {
++ await check(`OpenRe answering ${what} at pairing fails cleanly: the code stays usable and no device is made`, async () => {
++ const { robot, pairing } = await t.robot(alex);
++ const devicesBefore = Number((await t.db.maybe('SELECT count(*) AS n FROM devices')).n);
++ t.openre.failNext(fault);
++ const r = await redeem({ robot: robot.id, code: pairing.code });
++ t.openre.failNext(null);
++ assert.strictEqual(r.status, status, r.text);
++ assert.strictEqual(r.json.code, code);
++ if (fault === 403) assert.match(r.json.detail, /403 openre\.forbidden/, 'OpenRe\'s problem code is surfaced');
++ assert.ok(!r.text.includes(t.openre.token));
++ assert.strictEqual(Number((await t.db.maybe('SELECT count(*) AS n FROM devices')).n), devicesBefore, 'no device row');
++ assert.strictEqual((await robotRow(robot.id)).openre_stream_id, null, 'no stream id stored');
++ const code_ = await t.db.maybe('SELECT used_at, tries FROM pairing_codes WHERE robot_id = $1', [robot.id]);
++ assert.strictEqual(code_.used_at, null, 'the code is not spent');
++ const retry = await redeem({ robot: robot.id, code: pairing.code });
++ assert.strictEqual(retry.status, 201, retry.text);
++ assert.ok(t.openre.admits(retry.json.publish_key));
++ });
++ }
++
++ await check('the first pairing needs openre.stream.read too: a token without it is refused, `openre.stream.*` will do', async () => {
++ const { robot, pairing } = await t.robot(alex);
++ t.openre.setCaps(['openre.stream.write', 'openre.key.rotate']);
++ const r = await redeem({ robot: robot.id, code: pairing.code });
++ t.openre.setCaps(['openre.stream.*', 'openre.key.rotate']);
++ assert.strictEqual(r.status, 502, r.text);
++ assert.strictEqual(r.json.code, 'bot.openre_refused');
++ assert.match(r.json.detail, /403 capability\.denied/);
++ const retry = await redeem({ robot: robot.id, code: pairing.code });
++ t.openre.setCaps(null);
++ assert.strictEqual(retry.status, 201, retry.text);
++ assert.ok(t.openre.admits(retry.json.publish_key));
++ });
++
++ await check('a credential rotation OpenRe refuses changes nothing: the old credential stays the current one', async () => {
++ const { paired } = await pairedRobot();
++ const before = await deviceRow(paired.device_id);
++ t.openre.failNext(500);
++ const rot = await call('POST', `/api/v1/devices/${paired.device_id}/rotate`, { user: alex });
++ assert.strictEqual(rot.status, 503, rot.text);
++ assert.ok(!('credential' in rot.json));
++ const after = await deviceRow(paired.device_id);
++ assert.strictEqual(after.credential_hash, before.credential_hash);
++ assert.strictEqual(after.credential_prev_hash, null);
++ assert.strictEqual(after.publish_key_hint, before.publish_key_hint);
++ assert.ok(t.openre.admits(paired.publish_key));
++ });
++
++ await check('Bot\'s OpenRe token is never logged and never in an answer', async () => {
++ assert.ok(t.openre.calls.every((c) => c.authorization === `Bearer ${t.openre.token}` || c.authorization === null));
++ assert.ok(!t.logs.join('\n').includes(t.openre.token), 'the token was logged');
++ assert.ok(!responses.some((text) => text.includes(t.openre.token)), 'an answer carried the token');
++ });
++
++ await t.close();
++
++ // ── BOT_OPENRE_URL / BOT_OPENRE_TOKEN unset ───────────────────────────────────────────────────
++ const u = await boot({ openre: false, env: { BOT_WHIP_BASE: WHIP_BASE } });
++ const sam = u.network.newUser('sam');
++ await check('without OpenRe configured a device pairs, rotates and is removed with video reported not configured', async () => {
++ const { robot, pairing } = await u.robot(sam);
++ const paired = await u.call('POST', '/api/v1/pair', { token: null, body: { robot: robot.id, code: pairing.code } });
++ assert.strictEqual(paired.status, 201, paired.text);
++ assert.ok(paired.json.credential);
++ assert.strictEqual(paired.json.video, 'not_configured');
++ assert.ok(!('publish_key' in paired.json) && !('whip_url' in paired.json), paired.text);
++ const d = await u.db.maybe('SELECT * FROM devices WHERE id = $1', [paired.json.device_id]);
++ assert.strictEqual(d.publish_key_hash, null, 'no local key minted');
++ assert.strictEqual(d.publish_key_hint, null);
++ const rot = await u.call('POST', `/api/v1/devices/${paired.json.device_id}/rotate`, { user: sam });
++ assert.strictEqual(rot.status, 200, rot.text);
++ assert.strictEqual(rot.json.video, 'not_configured');
++ assert.ok(!('publish_key' in rot.json));
++ const rev = await u.call('POST', `/api/v1/devices/${paired.json.device_id}/revoke`, { user: sam });
++ assert.strictEqual(rev.status, 200, rev.text);
++ const del = await u.call('DELETE', `/api/v1/robots/${robot.id}`, { user: sam });
++ assert.strictEqual(del.status, 204, del.text);
++ });
++ await u.close();
++ done();
++})();