openvibers/openvibe.bot Commits
Bot slice B2, plus two findings from Bot PR 10's review (#11)
Changes
@@@ -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@@@ -8,6 +8,8 @@ Production runs the Node process under systemd as `openvibe-bot.service` (unit rTo 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.@@@ -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 itsowner'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 onEvery 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 capabilityshown; 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).@@@ -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]);@@@ -14,6 +14,7 @@ const int = (v, d) => { const n = parseInt(v, 10); return Number.isFinite(n) ? nconst 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),},@@@ -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);@@@ -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 };@@@ -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() {nnew file mode 100644@@@ -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();++})();@@@ -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);
@@ -38,6 +38,8 @@ OV_OAUTH_CLIENT_SECRET=
@@ -38,6 +38,8 @@ OV_OAUTH_CLIENT_SECRET=
#EVENTS_RELAY_INTERVAL_MS=2000
#EVENTS_RELAY_INTERVAL_MS=2000
# ── Pairing and device link (ADR-043) ───────────────────────
# ── 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_TTL_MS=600000
#BOT_PAIRING_MAX_TRIES=5
#BOT_PAIRING_MAX_TRIES=5
#BOT_HEARTBEAT_MS=1000
#BOT_HEARTBEAT_MS=1000
@@ -8,6 +8,8 @@ Production runs the Node process under systemd as `openvibe-bot.service` (unit r
@@ -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`.
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.
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.
`/install` is proxied to the app; the installer script itself ships with the T15 device-agent job.
@@ -29,6 +29,14 @@ header; such a socket may only send `pair` until it is answered (anything else
@@ -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**),
`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.
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
**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`
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
`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`.
@@ -64,7 +72,7 @@ At most 64 frames wait; any more are answered `error` `bot.not_ready`.
| type | fields (besides v, seq, ts) | notes |
| 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 |
| `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 |
| `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 |
| `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
@@ -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**
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
(`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
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 |
| Method & path | Auth (capability) | Answer |
|---|---|---|
|---|---|---|
| `GET /profiles`, `GET /profiles/:id` | public | `{ profiles[] }` / `{ profile }` |
| `GET /profiles`, `GET /profiles/:id` | public | `{ profiles[] }` / `{ profile }` |
| `GET /robots` | user (own robots), or `bot.robot.read` + `?owner=` | `{ robots[] }` |
| `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 }` |
| `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 |
| `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` |
| `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[] }` |
| `GET /robots/:id/operators` | member, or `bot.robot.read` | `{ operators[] }` |
| `POST /robots/:id/operators` | owner, or `bot.robot.manage` | `201 { operators[] }` |
| `POST /robots/:id/operators` | owner, or `bot.robot.manage` | `201 { operators[] }` |
| `DELETE /robots/:id/operators/:subject` | owner, or `bot.robot.manage` | `{ operators[] }` |
| `DELETE /robots/:id/operators/:subject` | owner, or `bot.robot.manage` | `{ operators[] }` |
@@ -325,7 +338,7 @@ but `POST /devices/bind`.
@@ -325,7 +338,7 @@ but `POST /devices/bind`.
| `POST /robots/:id/estop` | owner/operator, `bot.robot.control` | `{ robot }` |
| `POST /robots/:id/estop` | owner/operator, `bot.robot.control` | `{ robot }` |
| `POST /robots/:id/estop/clear` | **owner only**, `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) |
| `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
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).
`/auth/*` SSO routes, and `GET /` (a one-line text health placeholder).
@@ -24,6 +24,8 @@
@@ -24,6 +24,8 @@
* | POST /robots/:id/estop/clear | bot.robot.control | the owner only |
* | POST /robots/:id/estop/clear | bot.robot.control | the owner only |
* | GET /robots/:id/audit | bot.robot.read | owner (paged) |
* | GET /robots/:id/audit | bot.robot.read | owner (paged) |
* | POST /pair | — (one-time code) | the agent (code is the credential) |
* | 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 express = require('express');
const { http } = require('openvibe-contracts');
const { http } = require('openvibe-contracts');
@@ -69,7 +71,10 @@ function v1Router({ domain, apiAuth, limits, hub }) {
@@ -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 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`); };
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']) {
async function member(req, robotId, roles = ['owner', 'operator', 'viewer']) {
+ fence(req);
const robot = await domain.robots.get(robotId);
const robot = await domain.robots.get(robotId);
if (!robot) fail(404, 'bot.robot_not_found', 'no such robot');
if (!robot) fail(404, 'bot.robot_not_found', 'no such robot');
if (req.principal.kind === 'service') { requireRead(req); return { robot, role: 'service' }; }
if (req.principal.kind === 'service') { requireRead(req); return { robot, role: 'service' }; }
@@ -79,6 +84,7 @@ function v1Router({ domain, apiAuth, limits, hub }) {
@@ -79,6 +84,7 @@ function v1Router({ domain, apiAuth, limits, hub }) {
return { robot, role };
return { robot, role };
}
}
async function owner(req, robotId) {
async function owner(req, robotId) {
+ fence(req);
const robot = await domain.robots.get(robotId);
const robot = await domain.robots.get(robotId);
if (!robot) fail(404, 'bot.robot_not_found', 'no such robot');
if (!robot) fail(404, 'bot.robot_not_found', 'no such robot');
if (req.principal.kind === 'service') { requireManage(req); return robot; }
if (req.principal.kind === 'service') { requireManage(req); return robot; }
@@ -107,6 +113,8 @@ function v1Router({ domain, apiAuth, limits, hub }) {
@@ -107,6 +113,8 @@ function v1Router({ domain, apiAuth, limits, hub }) {
}));
}));
r.post('/robots', manage, wrap(async (req, res) => {
r.post('/robots', manage, wrap(async (req, res) => {
const ownerSubject = requireManage(req, req.body?.owner);
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({
const { robot, pairing } = await domain.robots.create({
owner: ownerSubject, name: req.body?.name, profile_id: req.body?.profile_id,
owner: ownerSubject, name: req.body?.name, profile_id: req.body?.profile_id,
access_policy: req.body?.access_policy, limits: req.body?.limits || {},
access_policy: req.body?.access_policy, limits: req.body?.limits || {},
@@ -158,6 +166,7 @@ function v1Router({ domain, apiAuth, limits, hub }) {
@@ -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) })) });
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) => {
r.post('/devices/:id/rotate', manage, wrap(async (req, res) => {
+ fence(req);
const d = await domain.devices.get(req.params.id);
const d = await domain.devices.get(req.params.id);
if (!d) fail(404, 'bot.device_not_found', 'no such device');
if (!d) fail(404, 'bot.device_not_found', 'no such device');
await owner(req, json(d.robot_ids, [])[0]);
await owner(req, json(d.robot_ids, [])[0]);
@@ -171,6 +180,7 @@ function v1Router({ domain, apiAuth, limits, hub }) {
@@ -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 } : {}) });
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) => {
r.post('/devices/:id/revoke', manage, wrap(async (req, res) => {
+ fence(req);
const d = await domain.devices.get(req.params.id);
const d = await domain.devices.get(req.params.id);
if (!d) fail(404, 'bot.device_not_found', 'no such device');
if (!d) fail(404, 'bot.device_not_found', 'no such device');
await owner(req, json(d.robot_ids, [])[0]);
await owner(req, json(d.robot_ids, [])[0]);
@@ -14,6 +14,7 @@ const int = (v, d) => { const n = parseInt(v, 10); return Number.isFinite(n) ? n
@@ -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 bool = (v, d = false) => (v == null || v === '' ? d : ['1', 'true', 'yes', 'on'].includes(String(v).toLowerCase()));
const trim = (u) => String(u || '').replace(/\/+$/, '');
const trim = (u) => String(u || '').replace(/\/+$/, '');
const list = (v) => String(v || '').split(',').map((s) => s.trim()).filter(Boolean);
const list = (v) => String(v || '').split(',').map((s) => s.trim()).filter(Boolean);
+const PAIRING_AUTHORITIES = ['bot', 'network'];
function loadConfig(env = process.env) {
function loadConfig(env = process.env) {
const nodeEnv = env.NODE_ENV || 'development';
const nodeEnv = env.NODE_ENV || 'development';
@@ -21,6 +22,10 @@ function loadConfig(env = process.env) {
@@ -21,6 +22,10 @@ function loadConfig(env = process.env) {
const port = int(env.PORT, 4630);
const port = int(env.PORT, 4630);
const networkUrl = trim(env.OV_NETWORK_URL || 'https://openvibe.network');
const networkUrl = trim(env.OV_NETWORK_URL || 'https://openvibe.network');
const baseUrl = trim(env.BASE_URL || (isProduction ? 'https://openvibe.bot' : `http://localhost:${port}`));
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 {
return {
nodeEnv,
nodeEnv,
isProduction,
isProduction,
@@ -64,8 +69,11 @@ function loadConfig(env = process.env) {
@@ -64,8 +69,11 @@ function loadConfig(env = process.env) {
},
},
// Pairing (ADR-043 decision 2): an 8-character Crockford base32 code, XXXX-XXXX, 10 minutes,
// 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: {
pairing: {
+ authority: pairingAuthority,
ttlMs: int(env.BOT_PAIRING_TTL_MS, 10 * 60 * 1000),
ttlMs: int(env.BOT_PAIRING_TTL_MS, 10 * 60 * 1000),
maxTries: int(env.BOT_PAIRING_MAX_TRIES, 5),
maxTries: int(env.BOT_PAIRING_MAX_TRIES, 5),
},
},
@@ -115,6 +115,8 @@ function createDomain({ db, config, outbox, link = null, nodes = null, now = ()
@@ -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');
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 id = prefixedId('rob', now());
const at = iso(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) => {
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)
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)`,
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 = ()
@@ -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]);
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]);
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 };
return { robot: created, pairing };
}
}
function cleanLimits(limits = {}) {
function cleanLimits(limits = {}) {
@@ -155,7 +157,9 @@ function createDomain({ db, config, outbox, link = null, nodes = null, now = ()
@@ -155,7 +157,9 @@ function createDomain({ db, config, outbox, link = null, nodes = null, now = ()
}
}
// ── Pairing (ADR-043 decision 2) ──────────────────────────────────────────────────────────────
// ── Pairing (ADR-043 decision 2) ──────────────────────────────────────────────────────────────
+ const networkPairing = () => config.pairing.authority === 'network';
async function createPairingCode(robotId, createdBy, installerUrl) {
async function createPairingCode(robotId, createdBy, installerUrl) {
+ if (networkPairing()) return mintOnNetwork(robotId, (await getRobot(robotId)).owner_subject, installerUrl);
const code = newCode();
const code = newCode();
const at = iso(now());
const at = iso(now());
const expires = iso(now() + config.pairing.ttlMs);
const expires = iso(now() + config.pairing.ttlMs);
@@ -171,6 +175,21 @@ function createDomain({ db, config, outbox, link = null, nodes = null, now = ()
@@ -171,6 +175,21 @@ function createDomain({ db, config, outbox, link = null, nodes = null, now = ()
const url = installerUrl || config.installer.scriptUrl;
const url = installerUrl || config.installer.scriptUrl;
return `curl -fsSL ${url} | sh -s -- --robot ${robotId} --code ${code}`;
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
* 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
* 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 = ()
@@ -178,6 +197,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, now = ()
* the clear.
* the clear.
*/
*/
async function redeem({ robot = null, code, agent_version = null, device_kind = 'onboard', drivers = [], capabilities = {}, name = null }) {
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);
const normal = normaliseCode(code);
if (!isCodeShape(normal)) fail(422, 'bot.invalid_pairing_code', 'the pairing code must be 8 characters (XXXX-XXXX)');
if (!isCodeShape(normal)) fail(422, 'bot.invalid_pairing_code', 'the pairing code must be 8 characters (XXXX-XXXX)');
const hash = hashSecret(normal);
const hash = hashSecret(normal);
@@ -6,8 +6,9 @@
@@ -6,8 +6,9 @@
* keys the Network's RS256 public key (OV_NETWORK_PUBLIC_KEY, else GET /api/.well-known/jwks,
* 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
* 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.
* (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 crypto = require('crypto');
const jwt = require('jsonwebtoken');
const jwt = require('jsonwebtoken');
@@ -75,6 +76,8 @@ function createUserAuth(config, keys) {
@@ -75,6 +76,8 @@ function createUserAuth(config, keys) {
* { principal, node_id, name, owner{kind,subject}, home_cell, status, paired_for{service,ref}|null, … }.
* { 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).
* 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.
* 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.
* Any other failure throws 503 bot.network_unavailable.
*/
*/
function createNodePrincipals(config, { fetchImpl = globalThis.fetch } = {}) {
function createNodePrincipals(config, { fetchImpl = globalThis.fetch } = {}) {
@@ -82,21 +85,28 @@ 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,
tokenUrl: `${config.network.internalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
audience: 'openvibe.network', scope: 'network.node.manage', fetchImpl,
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;
let res;
try {
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) {
} catch (e) {
throw new BotError(503, 'bot.network_unavailable', `Network did not answer for the machine: ${e.message}`);
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 === 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 };
module.exports = { createKeyProvider, createUserAuth, createNodePrincipals };
@@ -63,6 +63,12 @@ async function startNetwork() {
@@ -63,6 +63,12 @@ async function startNetwork() {
return r.claims.sub.replace(/^svc:/, '');
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) {
function newUser(username) {
return { subject: ids.newId('user'), username, display_name: username[0].toUpperCase() + username.slice(1) };
return { subject: ids.newId('user'), username, display_name: username[0].toUpperCase() + username.slice(1) };
}
}
@@ -76,6 +82,16 @@ async function startNetwork() {
@@ -76,6 +82,16 @@ async function startNetwork() {
const cap = String(body.scope || '').split(/\s+/).filter(Boolean);
const cap = String(body.scope || '').split(/\s+/).filter(Boolean);
return send(res, 200, { access_token: signService({ sub: `svc:${body.client_id}`, aud: [body.audience || 'openvibe.bot'], cap }), token_type: 'Bearer', expires_in: 300 });
return send(res, 200, { access_token: signService({ sub: `svc:${body.client_id}`, aud: [body.audience || 'openvibe.bot'], cap }), token_type: 'Bearer', expires_in: 300 });
}
}
+ 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);
const np = /^\/internal\/node-principals\/([^/]+)(\/revoke)?$/.exec(req.url);
if (np && (req.method === (np[2] ? 'POST' : 'GET'))) {
if (np && (req.method === (np[2] ? 'POST' : 'GET'))) {
const service = nodeManager(req);
const service = nodeManager(req);
@@ -92,7 +108,7 @@ async function startNetwork() {
@@ -92,7 +108,7 @@ async function startNetwork() {
});
});
const url = await listen(server);
const url = await listen(server);
issuer = url;
issuer = url;
- return { url, publicPem, signService, signUser, newUser, addNode, signNode, nodes, revokes, 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() {
async function startEvents() {
new file mode 100644
new file mode 100644
@@ -0,0 +1,108 @@
@@ -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();
+})();
@@ -2,6 +2,7 @@
@@ -2,6 +2,7 @@
// The owner fence on /api/v1: only a person (as themself) or a service with the capability (for the
// 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 →
// 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.
// 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.
// Public routes, POST /pair (its code) and POST /devices/bind (a node token) keep their own auth.
const assert = require('assert');
const assert = require('assert');
const { boot, check, done } = require('./helpers/app');
const { boot, check, done } = require('./helpers/app');
@@ -71,6 +72,46 @@ 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');
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 () => {
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', { token: null })).status, 200);
assert.strictEqual((await t.call('GET', '/api/v1/profiles/sim.rover', { token: null })).status, 200);
assert.strictEqual((await t.call('GET', '/api/v1/profiles/sim.rover', { token: null })).status, 200);