openvibers/openvibe.bot Commits

Bot slice B2, plus two findings from Bot PR 10's review (#11)

OP OpenVibers committed 2 Oct 2026 · 8d0c65b247 · 10 files, +250 −20

local checks ✓ 4/4 · no CI checks · review SHIP by Codex GPT-6 Sol

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

Changes

ddiff --git a/.env.example b/.env.example
iindex 4ee9c7c..87b3309 100644
---- a/.env.example
++++ b/.env.example
@@@ -38,6 +38,8 @@ OV_OAUTH_CLIENT_SECRET=
#EVENTS_RELAY_INTERVAL_MS=2000
# ── Pairing and device link (ADR-043) ───────────────────────
++# Who mints pairing codes: bot (the default) or network. Any other value refuses to boot.
++#BOT_PAIRING_AUTHORITY=bot
#BOT_PAIRING_TTL_MS=600000
#BOT_PAIRING_MAX_TRIES=5
#BOT_HEARTBEAT_MS=1000
ddiff --git a/README.md b/README.md
iindex c8fd622..a2f6039 100644
---- a/README.md
++++ b/README.md
@@@ -8,6 +8,8 @@ Production runs the Node process under systemd as `openvibe-bot.service` (unit r
To run the schema migrations before the service serves, start it once with the direct owner connection in `/etc/openvibe/bot.env` (`DATABASE_DIRECT_URL`); the boot applies `migrations/` and then serves on the pooled `DATABASE_URL`.
++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.
`/install` is proxied to the app; the installer script itself ships with the T15 device-agent job.
ddiff --git a/docs/protocol.md b/docs/protocol.md
iindex 3724857..0dfaafb 100644
---- a/docs/protocol.md
++++ b/docs/protocol.md
@@@ -29,6 +29,14 @@ header; such a socket may only send `pair` until it is answered (anything else
`bot.not_paired`). One socket per device: a second connection replaces the first (close code **4000**),
so a reconnecting agent cannot be shadowed by a stale socket.
++**Pairing authority** (`BOT_PAIRING_AUTHORITY`, `bot` by default, or `network`). With `network` the
++pairing code is minted by OpenVibe.Network (Bot calls `POST /internal/node-pairings` with its own service
++token, `{owner: {kind: 'user', subject}, ref: <robot id>}`) and Bot stores none; the installer command
++carries `--network <Network URL> --pairing pair_… --code XXXX-XXXX`, and the machine pairs on Network and
++connects with a node token (below). The `pair` frame is then answered `error` `bot.pairing_moved` (its
++`detail` names the Network URL) and the socket stays unpaired. Devices that already hold a Bot credential
++keep connecting with it either way.
++
**A Network-paired machine** (a node principal `nod_…`, paired on OpenVibe.Network for one of its
owner's robots) carries `Authorization: Bearer <node token>` instead: a Network JWT with `actor_type`
`node`, `sub` `node:nod_…` and audience `openvibe.bot`. Bot binds it to a device on first use (from
@@@ -64,7 +72,7 @@ At most 64 frames wait; any more are answered `error` `bot.not_ready`.
| type | fields (besides v, seq, ts) | notes |
|---|---|---|
--| `pair` | `robot, code, agent_version, device_kind, drivers[], capabilities{}, name` | the one-time pairing code; `robot` (from the installer command / QR) attributes a wrong try to that robot's code |
++| `pair` | `robot, code, agent_version, device_kind, drivers[], capabilities{}, name` | the one-time pairing code; `robot` (from the installer command / QR) attributes a wrong try to that robot's code. With `BOT_PAIRING_AUTHORITY=network` → `error` `bot.pairing_moved` |
| `heartbeat` | `t, rtt_ms` | every `heartbeat_ms` (1 s); `t` is the device's send time in unix ms (a finite number), returned as both `heartbeat_ack.echo` and `heartbeat_ack.t`; `rtt_ms` is the device's own measured latency |
| `telemetry` | `battery, voltage, sensors{}, events[]?, …` | at most 2 Hz; extra frames are dropped — except a frame with a non-empty `events` array (a fault, a bump, low battery), which is always delivered and does not count against the samples' window. `battery` is either a 0..1 fraction or an object `{volts, percent}` with `percent` 0..100 (as OpenVibe.Node sends it); `robot_state.battery` is always the fraction 0..1 (or `null`), while `robot_state.telemetry` keeps the raw frame |
| `status` | `firmware, capabilities, faults[], estop_latched, device_kind?, drivers[]?, agent_version?` | on connect and on change. See below |
@@@ -302,19 +310,24 @@ role that may drive (a queue robot's turn holder, not someone waiting) and an on
Every error is RFC 9457 `application/problem+json`. Authenticate with a Network **user token**
(`Authorization: Bearer`, people act on their own things) or a **service token** with the capability
shown; a service acts for `X-OV-Subject` / the body's `owner`. A person acts only as themself: naming
--anyone else (`?owner=`, the body's `owner`, `X-OV-Subject`) is `403 bot.forbidden`. Without a token, a
--route that acts for an owner answers `401 bot.sign_in`; a node token gets `403 bot.forbidden` everywhere
--but `POST /devices/bind`.
++anyone else (`?owner=`, the body's `owner`, `X-OV-Subject`) is `403 bot.forbidden`; `?owner=` is refused,
++never ignored, on both `GET /robots` and `POST /robots` (a service's `?owner=` on `POST /robots` must name
++the subject it acts for). Without a token, every route but `GET /profiles`, `GET /profiles/:id`, `POST /pair`
++and `POST /devices/bind` answers `401 bot.sign_in`: `GET|POST /robots`, `GET|PATCH|DELETE /robots/:id`,
++`POST /robots/:id/pairing-code`, `GET|POST /robots/:id/operators`, `DELETE /robots/:id/operators/:subject`,
++`GET /robots/:id/devices`, `POST /devices/:id/rotate|revoke`, `POST /robots/:id/estop`,
++`POST /robots/:id/estop/clear` and `GET /robots/:id/audit`. A node token gets `403 bot.forbidden` on the same
++routes. Both come before any lookup, so a real robot or device id answers exactly as a made-up one.
| Method & path | Auth (capability) | Answer |
|---|---|---|
| `GET /profiles`, `GET /profiles/:id` | public | `{ profiles[] }` / `{ profile }` |
| `GET /robots` | user (own robots), or `bot.robot.read` + `?owner=` | `{ robots[] }` |
--| `POST /robots` | the owner, or `bot.robot.manage` | `201 { robot, pairing{code,expires_at,installer} }` |
++| `POST /robots` | the owner, or `bot.robot.manage` | `201 { robot, pairing{code,expires_at,installer} }`; with `BOT_PAIRING_AUTHORITY=network` the pairing is Network's and also carries `pairing_id`, and `503 bot.network_unavailable` if Network did not answer (no robot is created) |
| `GET /robots/:id` | a member, or `bot.robot.read` | `{ robot, role }` |
| `PATCH /robots/:id` | owner, or `bot.robot.manage` | `{ robot }`; new `limits` (including `allow`) re-send `config` to the connected device |
| `DELETE /robots/:id` | owner, or `bot.robot.manage` | `204` |
--| `POST /robots/:id/pairing-code` | owner, or `bot.robot.manage` | `201 { code, expires_at, installer }` |
++| `POST /robots/:id/pairing-code` | owner, or `bot.robot.manage` | `201 { code, expires_at, installer }`; with `BOT_PAIRING_AUTHORITY=network` `201 { code, expires_at, pairing_id, installer }` minted by Network for the robot's owner, or `503 bot.network_unavailable` |
| `GET /robots/:id/operators` | member, or `bot.robot.read` | `{ operators[] }` |
| `POST /robots/:id/operators` | owner, or `bot.robot.manage` | `201 { operators[] }` |
| `DELETE /robots/:id/operators/:subject` | owner, or `bot.robot.manage` | `{ operators[] }` |
@@@ -325,7 +338,7 @@ but `POST /devices/bind`.
| `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) |
--| `POST /pair` | the one-time code is the credential | `201 { device_id, credential, publish_key, whip_url, robot_id, profile }` |
++| `POST /pair` | the one-time code is the credential | `201 { device_id, credential, publish_key, whip_url, robot_id, profile }`; with `BOT_PAIRING_AUTHORITY=network` always `410 bot.pairing_moved` (the `detail` names the Network URL) |
Not in `/api/v1`: `GET /api/health`, `GET /api/ready`, `GET /metrics`, `GET /release.json`, the
`/auth/*` SSO routes, and `GET /` (a one-line text health placeholder).
ddiff --git a/server/api/v1.js b/server/api/v1.js
iindex 89e65d9..507f9b2 100644
---- a/server/api/v1.js
++++ b/server/api/v1.js
@@@ -24,6 +24,8 @@
* | POST /robots/:id/estop/clear | bot.robot.control | the owner only |
* | GET /robots/:id/audit | bot.robot.read | owner (paged) |
* | POST /pair | — (one-time code) | the agent (code is the credential) |
++ *
++ * With BOT_PAIRING_AUTHORITY=network the pairing codes are Network's and POST /pair answers 410 bot.pairing_moved.
*/
const express = require('express');
const { http } = require('openvibe-contracts');
@@@ -69,7 +71,10 @@ function v1Router({ domain, apiAuth, limits, hub }) {
const requireRead = (req) => { fence(req); if (req.principal.kind === 'service' && !apiAuth.granted(req.principal, CAP.read)) fail(403, 'capability.denied', `${CAP.read} not granted`); };
const requireControlCap = (req) => { if (req.principal.kind === 'service' && !apiAuth.granted(req.principal, CAP.control)) fail(403, 'capability.denied', `${CAP.control} not granted`); };
++ // Both fence before the lookup: without a token (or with a node token) a real robot id and a made-up one
++ // get the same answer.
async function member(req, robotId, roles = ['owner', 'operator', 'viewer']) {
++ fence(req);
const robot = await domain.robots.get(robotId);
if (!robot) fail(404, 'bot.robot_not_found', 'no such robot');
if (req.principal.kind === 'service') { requireRead(req); return { robot, role: 'service' }; }
@@@ -79,6 +84,7 @@ function v1Router({ domain, apiAuth, limits, hub }) {
return { robot, role };
}
async function owner(req, robotId) {
++ fence(req);
const robot = await domain.robots.get(robotId);
if (!robot) fail(404, 'bot.robot_not_found', 'no such robot');
if (req.principal.kind === 'service') { requireManage(req); return robot; }
@@@ -107,6 +113,8 @@ function v1Router({ domain, apiAuth, limits, hub }) {
}));
r.post('/robots', manage, wrap(async (req, res) => {
const ownerSubject = requireManage(req, req.body?.owner);
++ // ?owner= is refused, never ignored: as on GET /robots it may name only the subject the call acts for.
++ if (req.query.owner != null && req.query.owner !== '' && req.query.owner !== ownerSubject) fail(403, 'bot.forbidden', 'you may only act for yourself');
const { robot, pairing } = await domain.robots.create({
owner: ownerSubject, name: req.body?.name, profile_id: req.body?.profile_id,
access_policy: req.body?.access_policy, limits: req.body?.limits || {},
@@@ -158,6 +166,7 @@ function v1Router({ domain, apiAuth, limits, hub }) {
res.json({ devices: rows.map((d) => ({ ...domain.present.device(d), online: hub.isOnline(d.id) })) });
}));
r.post('/devices/:id/rotate', manage, wrap(async (req, res) => {
++ fence(req);
const d = await domain.devices.get(req.params.id);
if (!d) fail(404, 'bot.device_not_found', 'no such device');
await owner(req, json(d.robot_ids, [])[0]);
@@@ -171,6 +180,7 @@ function v1Router({ domain, apiAuth, limits, hub }) {
res.json({ device: domain.present.device(rotated.device), credential: rotated.credential, publish_key: rotated.publish_key, ...(rotated.whip_url ? { whip_url: rotated.whip_url } : {}) });
}));
r.post('/devices/:id/revoke', manage, wrap(async (req, res) => {
++ fence(req);
const d = await domain.devices.get(req.params.id);
if (!d) fail(404, 'bot.device_not_found', 'no such device');
await owner(req, json(d.robot_ids, [])[0]);
ddiff --git a/server/config.js b/server/config.js
iindex 36bc71b..7f6bf16 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -14,6 +14,7 @@ const int = (v, d) => { const n = parseInt(v, 10); return Number.isFinite(n) ? n
const bool = (v, d = false) => (v == null || v === '' ? d : ['1', 'true', 'yes', 'on'].includes(String(v).toLowerCase()));
const trim = (u) => String(u || '').replace(/\/+$/, '');
const list = (v) => String(v || '').split(',').map((s) => s.trim()).filter(Boolean);
++const PAIRING_AUTHORITIES = ['bot', 'network'];
function loadConfig(env = process.env) {
const nodeEnv = env.NODE_ENV || 'development';
@@@ -21,6 +22,10 @@ function loadConfig(env = process.env) {
const port = int(env.PORT, 4630);
const networkUrl = trim(env.OV_NETWORK_URL || 'https://openvibe.network');
const baseUrl = trim(env.BASE_URL || (isProduction ? 'https://openvibe.bot' : `http://localhost:${port}`));
++ const pairingAuthority = env.BOT_PAIRING_AUTHORITY == null || env.BOT_PAIRING_AUTHORITY === '' ? 'bot' : env.BOT_PAIRING_AUTHORITY;
++ if (!PAIRING_AUTHORITIES.includes(pairingAuthority)) {
++ throw new Error(`BOT_PAIRING_AUTHORITY must be ${PAIRING_AUTHORITIES.join(' or ')}, not ${JSON.stringify(pairingAuthority)}`);
++ }
return {
nodeEnv,
isProduction,
@@@ -64,8 +69,11 @@ function loadConfig(env = process.env) {
},
// Pairing (ADR-043 decision 2): an 8-character Crockford base32 code, XXXX-XXXX, 10 minutes,
-- // single use, 5 wrong tries end it. Only the hash is stored.
++ // single use, 5 wrong tries end it. Only the hash is stored. `authority` network (plan T15 B2): the
++ // code is minted by Network (POST /internal/node-pairings), Bot stores none, and POST /pair and the
++ // `pair` frame answer 410 bot.pairing_moved. An unknown value refuses to boot.
pairing: {
++ authority: pairingAuthority,
ttlMs: int(env.BOT_PAIRING_TTL_MS, 10 * 60 * 1000),
maxTries: int(env.BOT_PAIRING_MAX_TRIES, 5),
},
ddiff --git a/server/domain/index.js b/server/domain/index.js
iindex b2b0a72..687874a 100644
---- a/server/domain/index.js
++++ b/server/domain/index.js
@@@ -115,6 +115,8 @@ function createDomain({ db, config, outbox, link = null, nodes = null, now = ()
if (!['private', 'invite', 'queue'].includes(access_policy)) fail(422, 'bot.invalid_policy', 'access_policy must be private, invite or queue');
const id = prefixedId('rob', now());
const at = iso(now());
++ // A Network-minted code comes first, so a Network that does not answer leaves no robot behind.
++ const minted = networkPairing() ? await mintOnNetwork(id, owner, installerUrl) : null;
const created = await db.tx(async (t) => {
await t.query(`INSERT INTO robots (id, owner_subject, name, profile_id, profile_version, access_policy, limits, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $8)`,
@@@ -122,7 +124,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, now = ()
await t.query(`INSERT INTO robot_operators (robot_id, subject, role, added_by, created_at) VALUES ($1, $2, 'owner', $2, $3)`, [id, owner, at]);
return t.maybe('SELECT * FROM robots WHERE id = $1', [id]);
});
-- const pairing = await createPairingCode(id, owner, installerUrl);
++ const pairing = minted || await createPairingCode(id, owner, installerUrl);
return { robot: created, pairing };
}
function cleanLimits(limits = {}) {
@@@ -155,7 +157,9 @@ function createDomain({ db, config, outbox, link = null, nodes = null, now = ()
}
// ── Pairing (ADR-043 decision 2) ──────────────────────────────────────────────────────────────
++ const networkPairing = () => config.pairing.authority === 'network';
async function createPairingCode(robotId, createdBy, installerUrl) {
++ if (networkPairing()) return mintOnNetwork(robotId, (await getRobot(robotId)).owner_subject, installerUrl);
const code = newCode();
const at = iso(now());
const expires = iso(now() + config.pairing.ttlMs);
@@@ -171,6 +175,21 @@ function createDomain({ db, config, outbox, link = null, nodes = null, now = ()
const url = installerUrl || config.installer.scriptUrl;
return `curl -fsSL ${url} | sh -s -- --robot ${robotId} --code ${code}`;
}
++ /**
++ * BOT_PAIRING_AUTHORITY=network (plan T15 B2): Network mints the code for the robot's owner (POST
++ * /internal/node-pairings, ref = the robot) and Bot stores none. The machine redeems it on Network and
++ * reaches Bot with a node token (bindNode). A Network that does not answer is 503 bot.network_unavailable.
++ */
++ async function mintOnNetwork(robotId, ownerSubject, installerUrl) {
++ if (!nodes) fail(503, 'bot.network_unavailable', 'Network pairing is not configured here');
++ const p = await nodes.pair({ subject: ownerSubject, ref: robotId });
++ if (!p || typeof p.pairing_id !== 'string' || typeof p.code !== 'string') fail(503, 'bot.network_unavailable', 'Network answered no pairing code');
++ const url = installerUrl || config.installer.scriptUrl;
++ return {
++ code: p.code, expires_at: p.expires_at, pairing_id: p.pairing_id,
++ installer: `curl -fsSL ${url} | sh -s -- --network ${config.network.url} --pairing ${p.pairing_id} --code ${p.code}`,
++ };
++ }
/**
* 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
@@@ -178,6 +197,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, now = ()
* the clear.
*/
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}`);
const normal = normaliseCode(code);
if (!isCodeShape(normal)) fail(422, 'bot.invalid_pairing_code', 'the pairing code must be 8 characters (XXXX-XXXX)');
const hash = hashSecret(normal);
ddiff --git a/server/network.js b/server/network.js
iindex eaf29d7..ebdc905 100644
---- a/server/network.js
++++ b/server/network.js
@@@ -6,8 +6,9 @@
* keys the Network's RS256 public key (OV_NETWORK_PUBLIC_KEY, else GET /api/.well-known/jwks,
* refreshed every 6 h and retried every 30 s until it loads). It verifies service tokens
* (audience openvibe.bot) and the browser's Network user JWT, both offline.
-- * nodes Network's node principals (machines paired for Bot): GET /internal/node-principals/:id and
-- * POST …/:id/revoke with Bot's own svc:bot token (audience openvibe.network, network.node.manage).
++ * 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).
*/
const crypto = require('crypto');
const jwt = require('jsonwebtoken');
@@@ -75,6 +76,8 @@ function createUserAuth(config, keys) {
* { principal, node_id, name, owner{kind,subject}, home_cell, status, paired_for{service,ref}|null, … }.
* get(id) → that view, or null when Network knows no such principal paired by Bot (it answers 404, never 403).
* revoke(id) → the revoked view, or null for the same 404; idempotent on Network's side.
++ * pair({subject, ref}) → { pairing_id, code, expires_at }: a one-time code for the person `subject` to pair a
++ * machine for robot `ref` (POST /internal/node-pairings; Network keeps one live code per ref).
* Any other failure throws 503 bot.network_unavailable.
*/
function createNodePrincipals(config, { fetchImpl = globalThis.fetch } = {}) {
@@@ -82,21 +85,28 @@ function createNodePrincipals(config, { fetchImpl = globalThis.fetch } = {}) {
tokenUrl: `${config.network.internalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
audience: 'openvibe.network', scope: 'network.node.manage', fetchImpl,
});
-- async function call(method, id) {
-- const path = `/internal/node-principals/${encodeURIComponent(id)}${method === 'POST' ? '/revoke' : ''}`;
++ async function call(method, path, body) {
let res;
try {
-- res = await fetchImpl(`${config.network.internalUrl}${path}`, { method, headers: { ...(await tokens.authHeaders()), Accept: 'application/json' }, signal: AbortSignal.timeout(8000) });
++ 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 for the machine: ${e.message}`);
}
if (res.status === 401) tokens.invalidate();
-- if (res.status === 404) return null;
-- const body = await res.json().catch(() => null);
-- if (!res.ok || !body) throw new BotError(503, 'bot.network_unavailable', `Network answered ${res.status} for the machine`);
-- return body;
++ if (res.status === 404 && !body) return null;
++ const out = await res.json().catch(() => null);
++ if (!res.ok || !out) throw new BotError(503, 'bot.network_unavailable', `Network answered ${res.status} for the machine`);
++ return out;
}
-- return { get: (id) => call('GET', id), revoke: (id) => call('POST', id) };
++ const principal = (id) => `/internal/node-principals/${encodeURIComponent(id)}`;
++ return {
++ get: (id) => call('GET', principal(id)),
++ revoke: (id) => call('POST', `${principal(id)}/revoke`),
++ pair: ({ subject, ref }) => call('POST', '/internal/node-pairings', { owner: { kind: 'user', subject }, ref }),
++ };
}
module.exports = { createKeyProvider, createUserAuth, createNodePrincipals };
ddiff --git a/test/helpers/stubs.js b/test/helpers/stubs.js
iindex 107d1cc..284b966 100644
---- a/test/helpers/stubs.js
++++ b/test/helpers/stubs.js
@@@ -63,6 +63,12 @@ async function startNetwork() {
return r.claims.sub.replace(/^svc:/, '');
}
++ // Node pairings (POST /internal/node-pairings): every call is kept; failPairings(status) answers that status
++ // instead (null: mint again).
++ const pairings = [];
++ let pairingFailure = null;
++ const failPairings = (status) => { pairingFailure = status; };
++
function newUser(username) {
return { subject: ids.newId('user'), username, display_name: username[0].toUpperCase() + username.slice(1) };
}
@@@ -76,6 +82,16 @@ 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 });
}
++ if (req.url === '/internal/node-pairings' && req.method === 'POST') {
++ const service = nodeManager(req);
++ if (!service) return send(res, 401, { code: 'token.invalid' });
++ if (pairingFailure) return send(res, pairingFailure, { code: 'registry.failed' });
++ const body = JSON.parse(await readBody(req) || '{}');
++ const code = crypto.randomBytes(4).toString('hex').toUpperCase().replace(/^(.{4})/, '$1-');
++ const out = { pairing_id: `pair_${ids.ulid()}`, code, expires_at: new Date(Date.now() + 600000).toISOString() };
++ pairings.push({ service, body, ...out });
++ return send(res, 201, out);
++ }
const np = /^\/internal\/node-principals\/([^/]+)(\/revoke)?$/.exec(req.url);
if (np && (req.method === (np[2] ? 'POST' : 'GET'))) {
const service = nodeManager(req);
@@@ -92,7 +108,7 @@ async function startNetwork() {
});
const url = await listen(server);
issuer = url;
-- return { url, publicPem, signService, signUser, newUser, addNode, signNode, nodes, revokes, close: () => new Promise((r) => server.close(r)) };
++ return { url, publicPem, signService, signUser, newUser, addNode, signNode, nodes, revokes, pairings, failPairings, close: () => new Promise((r) => server.close(r)) };
}
async function startEvents() {
ddiff --git a/test/network-pairing.test.js b/test/network-pairing.test.js
nnew file mode 100644
iindex 0000000..4d46ddd
---- /dev/null
++++ b/test/network-pairing.test.js
@@@ -0,0 +1,108 @@
++'use strict';
++// BOT_PAIRING_AUTHORITY (plan T15 B2). bot (the default): Bot mints and stores the pairing code, as before.
++// network: POST /robots and POST /robots/:id/pairing-code ask Network (POST /internal/node-pairings) for the
++// code and Bot stores none; POST /pair and the `pair` frame answer 410 bot.pairing_moved; Network not
++// answering is 503 bot.network_unavailable and creates no robot. An unknown value refuses to boot.
++const assert = require('assert');
++const path = require('path');
++const os = require('os');
++const { spawnSync } = require('child_process');
++const { boot, check, done } = require('./helpers/app');
++
++(async () => {
++ const codesFor = async (t, robotId) => Number((await t.db.maybe('SELECT count(*) AS n FROM pairing_codes WHERE robot_id = $1', [robotId])).n);
++
++ await check('an unknown BOT_PAIRING_AUTHORITY refuses to boot', async () => {
++ const { loadConfig } = require('../server/config');
++ assert.throws(() => loadConfig({ BOT_PAIRING_AUTHORITY: 'both' }), /BOT_PAIRING_AUTHORITY must be bot or network, not "both"/);
++ assert.strictEqual(loadConfig({}).pairing.authority, 'bot', 'bot is the default');
++ assert.strictEqual(loadConfig({ BOT_PAIRING_AUTHORITY: 'network' }).pairing.authority, 'network');
++ const run = spawnSync(process.execPath, [path.join(__dirname, '..', 'server', 'index.js')], {
++ cwd: os.tmpdir(), env: { ...process.env, NODE_ENV: 'test', BOT_PAIRING_AUTHORITY: 'Network' }, encoding: 'utf8', timeout: 20000,
++ });
++ assert.strictEqual(run.status, 1, run.stderr);
++ assert.match(run.stderr, /could not start: BOT_PAIRING_AUTHORITY must be bot or network, not "Network"/);
++ });
++
++ const bot = await boot();
++ await check('bot (the default): the code is minted and stored by Bot, Network is not asked', async () => {
++ const alex = bot.network.newUser('alex');
++ const { robot, pairing } = await bot.robot(alex);
++ assert.match(pairing.code, /^[0-9A-Z]{4}-[0-9A-Z]{4}$/);
++ assert.match(pairing.installer, new RegExp(`--robot ${robot.id} --code ${pairing.code}$`));
++ assert.strictEqual(pairing.pairing_id, undefined);
++ assert.strictEqual(await codesFor(bot, robot.id), 1);
++ assert.strictEqual(bot.network.pairings.length, 0);
++ const paired = await bot.call('POST', '/api/v1/pair', { token: null, body: { robot: robot.id, code: pairing.code } });
++ assert.strictEqual(paired.status, 201, paired.text);
++ });
++ await bot.close();
++
++ const t = await boot({ env: { BOT_PAIRING_AUTHORITY: 'network' } });
++ const alex = t.network.newUser('alex');
++
++ await check('network: POST /robots asks Network for the code and stores none', async () => {
++ const { robot, pairing } = await t.robot(alex);
++ const asked = t.network.pairings.at(-1);
++ assert.strictEqual(asked.service, 'bot', "Bot's own svc:bot token with network.node.manage");
++ assert.deepStrictEqual(asked.body, { owner: { kind: 'user', subject: alex.subject }, ref: robot.id });
++ assert.strictEqual(pairing.code, asked.code);
++ assert.strictEqual(pairing.pairing_id, asked.pairing_id);
++ assert.strictEqual(pairing.expires_at, asked.expires_at);
++ assert.strictEqual(pairing.installer, `curl -fsSL https://openvibe.bot/install | sh -s -- --network ${t.network.url} --pairing ${asked.pairing_id} --code ${asked.code}`);
++ assert.strictEqual(await codesFor(t, robot.id), 0);
++ });
++
++ await check('network: a new code is minted on Network for the robot\'s owner, also by a service', async () => {
++ const { robot } = await t.robot(alex);
++ const mine = await t.call('POST', `/api/v1/robots/${robot.id}/pairing-code`, { user: alex });
++ assert.strictEqual(mine.status, 201, mine.text);
++ assert.strictEqual(mine.json.pairing_id, t.network.pairings.at(-1).pairing_id);
++ const svc = await t.call('POST', `/api/v1/robots/${robot.id}/pairing-code`, { cap: ['bot.robot.manage'], headers: { 'X-OV-Subject': alex.subject } });
++ assert.strictEqual(svc.status, 201, svc.text);
++ assert.deepStrictEqual(t.network.pairings.at(-1).body, { owner: { kind: 'user', subject: alex.subject }, ref: robot.id });
++ assert.strictEqual(await codesFor(t, robot.id), 0);
++ });
++
++ await check('network: POST /pair and the pair frame answer 410 bot.pairing_moved with the Network URL', async () => {
++ const { robot, pairing } = await t.robot(alex);
++ const r = await t.call('POST', '/api/v1/pair', { token: null, body: { robot: robot.id, code: pairing.code } });
++ assert.strictEqual(r.status, 410, r.text);
++ assert.strictEqual(r.json.code, 'bot.pairing_moved');
++ assert.ok(r.json.detail.includes(t.network.url), r.text);
++ const dev = await t.ws('/device');
++ dev.send({ type: 'pair', robot: robot.id, code: pairing.code });
++ const err = await dev.waitFor((m) => m.type === 'error');
++ assert.strictEqual(err.code, 'bot.pairing_moved');
++ assert.ok(err.detail.includes(t.network.url));
++ assert.ok(!dev.messages.some((m) => m.type === 'paired'));
++ dev.close();
++ assert.strictEqual(Number((await t.db.maybe('SELECT count(*) AS n FROM devices')).n), 0, 'no device was made');
++ });
++
++ await check('network: Network not answering → 503 bot.network_unavailable, no robot and no local code', async () => {
++ const bob = t.network.newUser('bob');
++ const { robot } = await t.robot(bob);
++ t.network.failPairings(500);
++ try {
++ const created = await t.call('POST', '/api/v1/robots', { user: bob, body: { name: 'Offline', profile_id: 'sim.rover' } });
++ assert.strictEqual(created.status, 503, created.text);
++ assert.strictEqual(created.json.code, 'bot.network_unavailable');
++ const again = await t.call('POST', `/api/v1/robots/${robot.id}/pairing-code`, { user: bob });
++ assert.strictEqual(again.status, 503, again.text);
++ assert.strictEqual(again.json.code, 'bot.network_unavailable');
++ } finally { t.network.failPairings(null); }
++ const robots = await t.call('GET', '/api/v1/robots', { user: bob });
++ assert.deepStrictEqual(robots.json.robots.map((x) => x.id), [robot.id], 'the refused POST /robots left no robot');
++ assert.strictEqual(await codesFor(t, robot.id), 0);
++ });
++
++ await check('network: a machine paired on Network still binds (dual-accept)', async () => {
++ const { robot } = await t.robot(alex);
++ const bind = await t.call('POST', '/api/v1/devices/bind', { token: t.network.signNode(t.network.addNode({ owner: alex.subject, ref: robot.id })) });
++ assert.strictEqual(bind.status, 201, bind.text);
++ });
++
++ await t.close();
++ done();
++})();
ddiff --git a/test/owner-fence.test.js b/test/owner-fence.test.js
iindex 243e296..0b830fa 100644
---- a/test/owner-fence.test.js
++++ b/test/owner-fence.test.js
@@@ -2,6 +2,7 @@
// The owner fence on /api/v1: only a person (as themself) or a service with the capability (for the
// subject it names) reads or creates robots for an owner. No token → 401 bot.sign_in; a node token →
// 403 bot.forbidden; a person naming someone else (?owner, body owner, X-OV-Subject) → 403 bot.forbidden.
++// The fence comes before any lookup, so a real robot or device id answers exactly as a made-up one.
// Public routes, POST /pair (its code) and POST /devices/bind (a node token) keep their own auth.
const assert = require('assert');
const { boot, check, done } = require('./helpers/app');
@@@ -71,6 +72,46 @@ const { boot, check, done } = require('./helpers/app');
refused(await t.call('POST', '/api/v1/robots', { cap: ['bot.robot.manage'], body: { name: 'x', profile_id: 'sim.rover' } }), 422, 'bot.invalid_input');
});
++ await check('POST /robots?owner= naming anyone but the caller is refused, never ignored', async () => {
++ const before = await robotsOf(bob);
++ const body = { name: 'x', profile_id: 'sim.rover' };
++ refused(await t.call('POST', `/api/v1/robots?owner=${bob.subject}`, { user: alex, body }), 403, 'bot.forbidden');
++ refused(await t.call('POST', `/api/v1/robots?owner=${alex.subject}`, { cap: ['bot.robot.manage'], body: { ...body, owner: bob.subject } }), 403, 'bot.forbidden');
++ refused(await t.call('POST', `/api/v1/robots?owner=${bob.subject}`, { token: null, body }), 401, 'bot.sign_in');
++ assert.strictEqual(await robotsOf(bob), before);
++ const mine = await robotsOf(alex);
++ const own = await t.call('POST', `/api/v1/robots?owner=${alex.subject}`, { user: alex, body });
++ assert.strictEqual(own.status, 201, own.text);
++ assert.strictEqual(await robotsOf(alex), mine + 1);
++ });
++
++ await check('a real and a made-up robot id answer the same without a token or with a node token', async () => {
++ const fake = `rob_${'0'.repeat(26)}`;
++ const fakeDevice = `dev_${'0'.repeat(26)}`;
++ const { device_id: realDevice } = (await t.call('POST', '/api/v1/devices/bind', { token: nodeToken })).json;
++ const routes = (id, dev) => [
++ ['GET', `/robots/${id}`], ['PATCH', `/robots/${id}`, { name: 'x' }], ['DELETE', `/robots/${id}`],
++ ['POST', `/robots/${id}/pairing-code`], ['GET', `/robots/${id}/operators`], ['POST', `/robots/${id}/operators`, { subject: alex.subject }],
++ ['DELETE', `/robots/${id}/operators/${alex.subject}`], ['GET', `/robots/${id}/devices`], ['GET', `/robots/${id}/audit`],
++ ['POST', `/robots/${id}/estop`], ['POST', `/robots/${id}/estop/clear`], ['POST', `/devices/${dev}/rotate`], ['POST', `/devices/${dev}/revoke`],
++ ];
++ const shape = (r) => ({ status: r.status, code: r.json && r.json.code, title: r.json && r.json.title, detail: r.json && r.json.detail });
++ const real = routes(bobsRobot.id, realDevice);
++ const made = routes(fake, fakeDevice);
++ for (const [token, status, code] of [[null, 401, 'bot.sign_in'], [nodeToken, 403, 'bot.forbidden']]) {
++ for (let i = 0; i < real.length; i++) {
++ const [method, p, body] = real[i];
++ const a = await t.call(method, `/api/v1${p}`, { token, body });
++ const b = await t.call(made[i][0], `/api/v1${made[i][1]}`, { token, body: made[i][2] });
++ assert.deepStrictEqual(shape(a), { status, code, title: a.json.title, detail: a.json.detail }, `${method} ${p}: ${a.text}`);
++ assert.deepStrictEqual(shape(b), shape(a), `${method} ${made[i][1]} differs from the real id`);
++ }
++ }
++ const still = await t.call('GET', `/api/v1/robots/${bobsRobot.id}`, { user: bob });
++ assert.strictEqual(still.status, 200, 'nothing was changed');
++ assert.strictEqual(still.json.robot.name, bobsRobot.name);
++ });
++
await check('public routes, POST /pair and POST /devices/bind keep their own auth', async () => {
assert.strictEqual((await t.call('GET', '/api/v1/profiles', { token: null })).status, 200);
assert.strictEqual((await t.call('GET', '/api/v1/profiles/sim.rover', { token: null })).status, 200);