openvibers/openvibe.bot Commits
Accept Network node principals beside device credentials (#9)
Changes
@@@ -44,6 +44,7 @@ OV_OAUTH_CLIENT_SECRET=#BOT_OFFLINE_MISSES=2#BOT_OFFLINE_GRACE_MS=3000#BOT_ROTATE_GRACE_MS=60000++#BOT_NODE_REAUTH_MS=330000# ── Control ─────────────────────────────────────────────────#BOT_MAX_COMMAND_MS=300@@@ -29,6 +29,17 @@ 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.++**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++Network's record: the robot the machine was paired for, which must be one of the principal's owner's++robots, and the principal must be `active`), then speaks first exactly as for a credential — `hello`,++`config` — with **no** `paired` frame. A refused node token, or a principal Bot will not bind (revoked,++paired for another service, for someone else's robot, or revoked here), closes the socket with **4002**.++A node token lives 300 s: the machine sends `reauth` with a fresh one before it expires; with no valid++`reauth` within 330 s of the last token Bot closes the socket with **4002**. Any other Bearer is a device++credential, as above.++Frames sent while authentication is still in progress — right after an upgrade that carries theheader, or after `pair` until it is answered — are **kept** and handled in arrival order once itcompletes (after `hello` and `config`), so a device may send its first `status` and `estop_state`@@@ -46,6 +57,7 @@ At most 64 frames wait; any more are answered `error` `bot.not_ready`.| `command` | `id, kind, value, deadline_ms, operator{subject,role}, robot_id` | an operator's command passed the gate || `estop` | `latched, by, at` | the e-stop latched or the owner cleared it || `heartbeat_ack` | `echo, t, server_time` | the answer to `heartbeat`; `echo` is the heartbeat's `t` (or `null`), so the device computes RTT, and `t` repeats a finite `t` (device ms) unchanged — the field OpenVibe.Node reads. The envelope `seq` is Bot's own counter and never echoes the device's |++| `rotate` | — | the owner forced a rotation of a Network-paired machine (`POST /devices/:id/rotate`); the machine rotates its credential with Network itself || `error` | `code, detail` | a frame the server refused |### Device → server@@@ -55,7 +67,8 @@ At most 64 frames wait; any more are answered `error` `bot.not_ready`.| `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 || `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` | on connect and on change |++| `status` | `firmware, capabilities, faults[], estop_latched, device_kind?, drivers[]?, agent_version?` | on connect and on change. See below |++| `reauth` | `token` | a Network-paired machine only: a fresh node token for the same principal, before the last one expires (300 s); a refused one is answered `error` `bot.reauth_refused` and moves nothing || `ack` | `id` | a command ran || `nack` | `id, fault_code` | a command was refused on the device || `estop_state` | `latched, by, at, robot_id?` | the device's own latch, a **report**; `robot_id` omitted = every robot the device serves. See below |@@@ -67,6 +80,13 @@ kinds its robot's profile declares (§1.1), with values in the shapes and rangeson every disconnect or crash path, without asking the network. `halt` (value `{}`, no deadline) stopseverything at once and passes the gate even while the e-stop is latched.++`status` of a Network-paired machine also says what it is: `device_kind` (`onboard` \| `bridge` \|++`server`), `drivers` (an array of strings), `capabilities` (an object) and `agent_version` (a string of at++most 40 characters), each optional. Bot stores them on the device row when they differ from it (the row++starts as `onboard`, no drivers, `{}`, no version); if any of them is invalid the frame is answered `error`++`bot.bad_frame` and the row is left unchanged. For a credential device these fields are not stored: what++it declared at pairing stays.++`config.limits` are the **effective** limits — the owner's (`PATCH /robots/:id` `limits`) clamped by theprofile's — as `{ max_speed, max_turn, max_command_ms, heartbeat_ms }`, the same numbers the gate clampswith; `allowed_commands` is what the owner may send: the profile's kinds, cut by the owner's allowlist,@@@ -296,8 +316,9 @@ shown; a service acts for `X-OV-Subject` / the body's `owner`.| `POST /robots/:id/operators` | owner, or `bot.robot.manage` | `201 { operators[] }` || `DELETE /robots/:id/operators/:subject` | owner, or `bot.robot.manage` | `{ operators[] }` || `GET /robots/:id/devices` | member, or `bot.robot.read` | `{ devices[] }` (no hashes, `online`) |--| `POST /devices/:id/rotate` | the robot's owner, `bot.device.connect` | `{ device, credential, publish_key, whip_url }` (once) |--| `POST /devices/:id/revoke` | the robot's owner, `bot.device.connect` | `{ device }`; the socket closes at once |++| `POST /devices/:id/rotate` | the robot's owner, `bot.device.connect` | `{ device, credential, publish_key, whip_url }` (once); for a Network-paired machine `{ device, sent }` — the machine is sent `rotate`, no credential is answered |++| `POST /devices/:id/revoke` | the robot's owner, `bot.device.connect` | `{ device }`; the socket closes at once. A Network-paired machine's principal is revoked on Network too (`503 bot.network_unavailable` if Network did not answer: revoked here, retry) and never binds again |++| `POST /devices/bind` | a Network node token (audience `openvibe.bot`), no body | `201 { device_id, publish_key, whip_url, robot_id, profile }` — `POST /pair`'s answer without `credential`; again → the same device and a new publish key (the old one stops working). Refused: `401 bot.node_token_required`, `403 bot.node_not_bound` || `POST /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) |nnew file mode 100644@@@ -0,0 +1,12 @@++-- phase: expand++--++-- The node principal (T2 lane B, OpenVibe.Network docs/t2-cells-and-node-principal.md §9.2 B1): a device may++-- be bound to a Network node principal (nod_…) instead of holding a Bot credential. Dual-accept, additive only:++-- every credential device keeps working unchanged; a Network-paired device has node_principal and no++-- credential_hash. The existing devices_credential unique index already ignores revoked rows, and NULLs never++-- collide in it.++++ALTER TABLE devices ADD COLUMN node_principal text; -- nod_<ULID>, Network's principal; NULL for a credential device++-- bindNode: the live device of one principal (at most one).++CREATE UNIQUE INDEX devices_node_principal ON devices (node_principal) WHERE node_principal IS NOT NULL AND revoked_at IS NULL;++ALTER TABLE devices ALTER COLUMN credential_hash DROP NOT NULL;@@@ -9,7 +9,7 @@ const { createActorLimiter, createValkeyLimitStore, defaultActor } = require('opfunction actor(req) {const p = req.principal;-- if (p && p.kind === 'service' && p.sub) return p.sub;++ if (p && (p.kind === 'service' || p.kind === 'node') && p.sub) return p.sub;if (p && p.kind === 'user' && p.subject) return `user:${p.subject}`;return defaultActor(req);}@@@ -5,6 +5,8 @@** { kind: 'service', sub: 'svc:live', cap: [...] } a Network client-credentials token for audience* openvibe.bot; each route checks ONE capability++ * { kind: 'node', principal: 'nod_…', sub: 'node:nod_…' } a Network node token (actor_type node, audience++ * openvibe.bot): a machine paired for Bot; it may only bind* { kind: 'user', subject: 'usr_…', username, name, avatar, role }* { kind: 'anonymous' }*@@@ -12,7 +14,8 @@*/const { serviceAuth, capabilities, http, ids } = require('openvibe-contracts');--const PRINCIPAL_SUB = /^(svc|app|mod):/;++const PRINCIPAL_SUB = /^(svc|app|mod|node):/;++const NODE_SUB = /^node:(nod_[0-9A-HJKMNP-TV-Z]{26})$/;const ANON = Object.freeze({ kind: 'anonymous' });function decodePayload(token) {@@@ -42,6 +45,25 @@ function verifyService(token, { publicKey, issuer, audience }) {return { ok: true, claims: r.claims };}++/** A Bearer that claims to be a node token (actor_type node); verifyNode decides whether it is one. */++function isNodeToken(token) {++ const payload = decodePayload(token);++ return !!payload && payload.actor_type === 'node';++}++++/**++ * Verify a node token: signed by Network, audience openvibe.bot, actor_type node, sub node:nod_…++ * Returns { ok, principal: 'nod_…', claims } or { ok:false, code, reason }.++ */++function verifyNode(token, { publicKey, issuer, audience }) {++ if (!publicKey) return { ok: false, code: 'identity.unavailable', reason: 'the Network signing key is not loaded yet' };++ const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer, audience });++ if (!r.ok) return { ok: false, code: r.code, reason: r.reason };++ const m = NODE_SUB.exec(String(r.claims.sub || ''));++ if (r.claims.actor_type !== 'node' || !m) return { ok: false, code: 'token.invalid', reason: 'not a node token' };++ return { ok: true, principal: m[1], claims: r.claims };++}++function createApiAuth({ config, keys, userAuth }) {function resolve(req) {const header = String(req.headers.authorization || '');@@@ -50,6 +72,11 @@ function createApiAuth({ config, keys, userAuth }) {const publicKey = keys.get();if (!publicKey) return { error: [503, 'identity.unavailable', 'the Network signing key is not loaded yet'] };const payload = decodePayload(token);++ if (payload && payload.actor_type === 'node') {++ const r = verifyNode(token, { publicKey, issuer: config.network.issuer, audience: config.audience });++ if (!r.ok) return { error: [401, r.code, r.reason] };++ return { principal: { kind: 'node', principal: r.principal, sub: r.claims.sub, jti: r.claims.jti } };++ }if (payload && typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) {const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.network.issuer, audience: config.audience });if (!r.ok) return { error: [401, r.code, r.reason] };@@@ -74,4 +101,4 @@ function createApiAuth({ config, keys, userAuth }) {return { middleware, resolve, granted };}--module.exports = { createApiAuth, userPrincipal, verifyService, decodePayload, PRINCIPAL_SUB };++module.exports = { createApiAuth, userPrincipal, verifyService, verifyNode, isNodeToken, decodePayload, PRINCIPAL_SUB };@@@ -19,6 +19,7 @@* | DELETE /robots/:id/operators/:subject | bot.robot.manage | owner |* | GET /robots/:id/devices | bot.robot.read | a member |* | POST /devices/:id/rotate, /revoke | bot.device.connect | the robot's owner |++ * | POST /devices/bind | — (a node token) | a machine paired for Bot on Network |* | POST /robots/:id/estop | bot.robot.control | owner or operator |* | POST /robots/:id/estop/clear | bot.robot.control | the owner only |* | GET /robots/:id/audit | bot.robot.read | owner (paged) |@@@ -148,6 +149,12 @@ function v1Router({ domain, apiAuth, limits, hub }) {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]);++ if (d.node_principal) {++ // A Network-paired machine rotates its credential with Network itself; Bot only asks it to.++ if (d.revoked_at) fail(404, 'bot.device_not_found', 'no live device');++ const sent = hub.sendToDevice(d.id, { type: 'rotate' });++ return res.json({ device: domain.present.device(d), sent });++ }const rotated = await domain.devices.rotate(req.params.id);res.json({ device: domain.present.device(rotated.device), credential: rotated.credential, publish_key: rotated.publish_key, ...(rotated.whip_url ? { whip_url: rotated.whip_url } : {}) });}));@@@ -156,7 +163,11 @@ function v1Router({ domain, apiAuth, limits, hub }) {if (!d) fail(404, 'bot.device_not_found', 'no such device');await owner(req, json(d.robot_ids, [])[0]);const revoked = await domain.devices.revoke(req.params.id);++ // A Network-paired machine is revoked on Network too (Bot never binds that principal again either way).++ let networkError = null;++ if (d.node_principal) await domain.devices.revokeNode(d.node_principal).catch((e) => { networkError = e; });hub.closeDevice(req.params.id, 'revoked'); // revocation disconnects the device at once++ if (networkError) throw networkError; // revoked here; the owner retries for Networkres.json({ device: domain.present.device(revoked) });}));@@@ -199,6 +210,17 @@ function v1Router({ domain, apiAuth, limits, hub }) {res.status(201).json({ device_id: result.device.id, credential: result.credential, publish_key: result.publish_key, ...(result.whip_url ? { whip_url: result.whip_url } : {}), robot_id: json(result.device.robot_ids, [])[0], profile: result.profile });}));++ // ── Bootstrap of a Network-paired machine (its node token, once after pairing) ────────────────++ // Binds the machine to its robot and issues its WHIP publish key; calling again re-issues the key (the++ // old one stops working) for the same device. POST /pair's answer without a credential.++ r.post('/devices/bind', pair, wrap(async (req, res) => {++ if (req.principal.kind !== 'node') fail(401, 'bot.node_token_required', 'a Network node token (audience openvibe.bot) is required');++ const device = await domain.devices.bindNode(req.principal.principal);++ const issued = await domain.devices.issuePublishKey(device.id);++ res.setHeader('Cache-Control', 'no-store');++ res.status(201).json({ device_id: issued.device.id, publish_key: issued.publish_key, ...(issued.whip_url ? { whip_url: issued.whip_url } : {}), robot_id: json(issued.device.robot_ids, [])[0], profile: issued.profile });++ }));++return r;}@@@ -9,7 +9,7 @@* /auth/* Network SSO session for people (the control WS accepts the ov_token cookie)* GET / a one-line text placeholder; the panel and the site are designed separately*-- * createApp({ config, db, valkey, registry, keys, hub, outbox, now, fetchImpl, log }) — everything++ * createApp({ config, db, valkey, registry, keys, hub, outbox, nodes, now, fetchImpl, log }) — everything* injectable. `db` is an openvibe-sdk/db handle with the schema migrated; `hub` the realtime WebSocket* hub (server/realtime.js), whose handleUpgrade the caller attaches to the HTTP server.*/@@@ -20,7 +20,7 @@ const { http } = require('openvibe-contracts');const { isLoopbackDirect } = require('openvibe-shared/metrics');const { loadConfig } = require('./config');const { openDb } = require('./db');--const { createKeyProvider, createUserAuth } = require('./network');++const { createKeyProvider, createUserAuth, createNodePrincipals } = require('./network');const { createBotOutbox } = require('./events/outbox');const { createDomain } = require('./domain');const { createRealtime } = require('./realtime');@@@ -45,7 +45,8 @@ function createApp(opts = {}) {const userAuth = createUserAuth(config, keys);const outbox = opts.outbox || createBotOutbox({ db, config, fetchImpl: opts.eventsFetch, now, log });const hub = opts.hub || createRealtime({ config, keys, userAuth, log, now });-- const domain = createDomain({ db, config, outbox, link: hub, now, log });++ const nodes = opts.nodes || createNodePrincipals(config, { fetchImpl });++ const domain = createDomain({ db, config, outbox, link: hub, nodes, now, log });hub.bindDomain(domain);const apiAuth = createApiAuth({ config, keys, userAuth });const release = require('openvibe-shared/release').createRelease({ service: 'bot', root: path.join(__dirname, '..') });@@@ -76,6 +76,9 @@ function loadConfig(env = process.env) {offlineMisses: int(env.BOT_OFFLINE_MISSES, 2),offlineGraceMs: int(env.BOT_OFFLINE_GRACE_MS, 3000),rotateGraceMs: int(env.BOT_ROTATE_GRACE_MS, 60 * 1000),++ // A Network-paired machine's node token lives 300 s; with no valid `reauth` within 330 s of the++ // last token the socket closes 4002.++ nodeReauthMs: int(env.BOT_NODE_REAUTH_MS, 330 * 1000),},// Control gate (ADR-043 decision 5/6/8). maxCommandMs is the absolute deadline cap; the queue is// the only way a stranger drives a robot.@@@ -15,7 +15,7 @@* stop is never refused to someone who may drive. Every decision — allowed or refused — is audited.*/const {-- BotError, fail, prefixedId, iso, token, hashSecret, secretEquals, json, text, storable,++ BotError, fail, prefixedId, iso, token, hashSecret, secretEquals, json, text, storable, isRobotId,} = require('../util');const { getProfile } = require('../profiles');const { ENVELOPE } = require('../events/outbox');@@@ -59,7 +59,7 @@ function noteToHz(note) {return 440 * 2 ** ((12 * (Number(m[3]) + 1) + semis - 69) / 12);}--function createDomain({ db, config, outbox, link = null, now = () => Date.now(), log = console }) {++function createDomain({ db, config, outbox, link = null, nodes = null, now = () => Date.now(), log = console }) {// ── Presenters ────────────────────────────────────────────────────────────────────────────────const presentRobot = (r) => (r ? {id: r.id, name: r.name, profile_id: r.profile_id, profile_version: r.profile_version,@@@ -264,6 +264,68 @@ function createDomain({ db, config, outbox, link = null, now = () => Date.now(),const updated = await db.maybe('UPDATE devices SET revoked_at = $2, credential_prev_hash = NULL, prev_valid_until = NULL, updated_at = $2 WHERE id = $1 RETURNING *', [id, at]);return updated;}++++ // ── Network-paired devices (node principals, T2 §9.2 B1) ──────────────────────────────────────++ /**++ * The live device bound to Network node principal `principalId`, creating it on first use. Idempotent:++ * a live row is returned as it is. Otherwise Network's record decides (GET /internal/node-principals/:id++ * with Bot's service token): the principal must be active, paired for Bot, for a robot of the principal's++ * own owner; anything else is 403 bot.node_not_bound. A principal whose device the owner revoked here++ * never binds again. The row starts from Network's record and safe values only; what the device declares++ * arrives later in its `status` frames.++ */++ async function bindNode(principalId) {++ const known = await db.maybe(++ 'SELECT * FROM devices WHERE node_principal = $1 ORDER BY (revoked_at IS NULL) DESC, created_at DESC LIMIT 1', [principalId]);++ if (known && !known.revoked_at) return known;++ const refuse = (why) => fail(403, 'bot.node_not_bound', why);++ if (known) refuse('this machine was revoked on Bot; pair it again');++ if (!nodes) refuse('Network-paired machines are not configured here');++ const p = await nodes.get(principalId);++ if (!p || p.principal !== principalId) refuse('Network knows no such machine paired for Bot');++ if (p.status !== 'active') refuse(`the machine is ${p.status} on Network`);++ if (!p.paired_for || p.paired_for.service !== 'bot') refuse('the machine was not paired for Bot');++ const robot = isRobotId(p.paired_for.ref) ? await getRobot(p.paired_for.ref) : null;++ if (!robot || !p.owner || p.owner.kind !== 'user' || p.owner.subject !== robot.owner_subject) refuse("the machine was not paired for one of its owner's robots");++ const at = iso(now());++ const name = typeof p.name === 'string' ? storable(p.name).trim().slice(0, 80) || null : null;++ // Two first connections at once: the unique index lets one insert win, and the other reads it.++ const inserted = await db.maybe(++ `INSERT INTO devices (id, robot_ids, name, kind, agent_version, drivers, capabilities, credential_hash, node_principal, created_at, updated_at)++ VALUES ($1, $2::jsonb, $3, 'onboard', NULL, '[]'::jsonb, '{}'::jsonb, NULL, $4, $5, $5)++ ON CONFLICT (node_principal) WHERE node_principal IS NOT NULL AND revoked_at IS NULL DO NOTHING RETURNING *`,++ [prefixedId('dev', now()), JSON.stringify([robot.id]), name, principalId, at]);++ return inserted || db.maybe('SELECT * FROM devices WHERE node_principal = $1 AND revoked_at IS NULL', [principalId]);++ }++ /**++ * Issue (or re-issue) a device's WHIP publish key: the new key replaces the old one, which stops working.++ * → { device, publish_key, whip_url?, profile }, the key shown this once.++ */++ async function issuePublishKey(id) {++ const publishKey = token(32);++ const device = await db.maybe('UPDATE devices SET publish_key_hash = $2, updated_at = $3 WHERE id = $1 AND revoked_at IS NULL RETURNING *',++ [id, hashSecret(publishKey), iso(now())]);++ if (!device) fail(404, 'bot.device_not_found', 'no live device');++ const robot = await getRobot(json(device.robot_ids, [])[0]);++ const profile = robot ? await getProfile(db, robot.profile_id) : null;++ const whUrl = whipUrl(publishKey);++ return { device, publish_key: publishKey, ...(whUrl ? { whip_url: whUrl } : {}), profile: profile ? profile.profile : null };++ }++ /** Persist what a Network-paired device declares (kind, drivers, capabilities, agent_version): only the given fields. */++ async function updateDeclared(id, { kind, drivers, capabilities, agent_version: agentVersion }) {++ return db.maybe(++ `UPDATE devices SET kind = COALESCE($2, kind), drivers = COALESCE($3::jsonb, drivers), capabilities = COALESCE($4::jsonb, capabilities),++ agent_version = CASE WHEN $5 THEN $6 ELSE agent_version END, updated_at = $7++ WHERE id = $1 AND revoked_at IS NULL RETURNING *`,++ [id, kind ?? null, drivers ? JSON.stringify(drivers) : null, capabilities ? JSON.stringify(capabilities) : null,++ agentVersion !== undefined, agentVersion ?? null, iso(now())]);++ }++ /** Revoke a Network-paired device's principal on Network too (null: Network no longer knows it). */++ async function revokeNode(principalId) {++ if (!nodes) fail(503, 'bot.network_unavailable', 'Network-paired machines are not configured here');++ return nodes.revoke(principalId);++ }++async function touchSeen(id) { await db.query('UPDATE devices SET last_seen = $2 WHERE id = $1', [id, iso(now())]); }async function setOnline(device, online) {@@@ -591,7 +653,7 @@ function createDomain({ db, config, outbox, link = null, now = () => Date.now(),robots: { create: createRobot, list: listRobots, get: getRobot, update: updateRobot, remove: removeRobot },members: { roleOf, add: addOperator, remove: removeOperator, list: listOperators },pairing: { create: createPairingCode, redeem, prune: prunePairingCodes, installerCommand, whipUrl },-- devices: { byCredential, get: getDevice, listForRobot: listDevicesForRobot, rotate: rotateDevice, revoke: revokeDevice, touchSeen, setOnline },++ devices: { byCredential, bindNode, issuePublishKey, updateDeclared, revokeNode, get: getDevice, listForRobot: listDevicesForRobot, rotate: rotateDevice, revoke: revokeDevice, touchSeen, setOnline },estop: { set: setEstop, clear: clearEstop },queue: { join: joinQueue, state: queueState, currentTurn, consume: consumeTurn, sweep: sweepQueues },audit: { record: auditCommand, list: listAudit, listPage: listAuditPage, prune: pruneAudit },@@@ -6,10 +6,14 @@* 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).*/const crypto = require('crypto');const jwt = require('jsonwebtoken');const { OpenVibeAuthClient } = require('openvibe-shared/auth-client');++const { serviceAuth } = require('openvibe-contracts');++const { BotError } = require('./util');function createKeyProvider(config, { fetchImpl = globalThis.fetch, log = console } = {}) {let pem = config.network.publicKey ? crypto.createPublicKey(config.network.publicKey).export({ type: 'spki', format: 'pem' }) : null;@@@ -59,11 +63,40 @@ function createUserAuth(config, keys) {let claims;try { claims = jwt.verify(token, key, { algorithms: ['RS256'], issuer: config.network.issuer }); } catch { return null; }if (!claims || typeof claims !== 'object') return null;-- if (typeof claims.sub === 'string' && /^(svc|app|mod):/.test(claims.sub)) return null;-- if (claims.actor_type === 'service') return null;++ if (typeof claims.sub === 'string' && /^(svc|app|mod|node):/.test(claims.sub)) return null;++ if (claims.actor_type === 'service' || claims.actor_type === 'node') return null;return claims;}return { client, verify };}--module.exports = { createKeyProvider, createUserAuth };++/**++ * Network's view of a node principal (OpenVibe.Network server/registry/node-principals.js principalView):++ * { 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.++ * Any other failure throws 503 bot.network_unavailable.++ */++function createNodePrincipals(config, { fetchImpl = globalThis.fetch } = {}) {++ const tokens = serviceAuth.createTokenClient({++ 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' : ''}`;++ let res;++ try {++ res = await fetchImpl(`${config.network.internalUrl}${path}`, { method, headers: { ...(await tokens.authHeaders()), Accept: 'application/json' }, 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;++ }++ return { get: (id) => call('GET', id), revoke: (id) => call('POST', id) };++}++++module.exports = { createKeyProvider, createUserAuth, createNodePrincipals };@@@ -5,7 +5,8 @@** wss://…/device one outbound connection per device. Auth by `Authorization: Bearer <credential>`* on the upgrade, never a query string; an unauthenticated socket may only send-- * `pair`. Heartbeats every 1 s; offline after 2 missed + 3 s grace. Commands are++ * `pair`. A Network-paired machine presents its node token instead (bound on first++ * use) and renews it with `reauth` before the 330 s deadline, else close 4002. Heartbeats every 1 s; offline after 2 missed + 3 s grace. Commands are* never queued for an offline device and never replayed after a reconnect.* wss://…/control a signed-in person (Network session cookie or Bearer) or a service token holding* `bot.robot.control` acting for `X-OV-Subject`. Joins a robot, sends commands; the@@@ -17,12 +18,14 @@const { WebSocketServer } = require('ws');const { capabilities, ids } = require('openvibe-contracts');const { json, iso, prefixedId } = require('./util');--const { userPrincipal, verifyService, PRINCIPAL_SUB, decodePayload } = require('./api/auth');++const { userPrincipal, verifyService, verifyNode, isNodeToken, PRINCIPAL_SUB, decodePayload } = require('./api/auth');const { getProfile } = require('./profiles');const OPEN = 1;const MAX_BACKLOG = 64; // frames a device may send before its authentication completesconst ACCESS_COOKIE = 'ov_token';++const DEVICE_KINDS = new Set(['onboard', 'bridge', 'server']);++const MAX_AGENT_VERSION = 40;function parseCookies(header) {const out = {};@@@ -79,7 +82,8 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Datif (cred) {conn.backlog = [];try {-- await authenticateDevice(conn, cred);++ if (isNodeToken(cred)) await authenticateNode(conn, cred);++ else await authenticateDevice(conn, cred);sendHello(conn);await sendConfig(conn);await bringOnline(conn);@@@ -112,6 +116,48 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => DatattachDevice(conn, device);}++ /** A node token: verified offline, then bound to its device (created on first use from Network's record). */++ async function authenticateNode(conn, nodeToken) {++ const r = verifyNode(nodeToken, { publicKey: keys.get(), issuer: config.network.issuer, audience: config.audience });++ if (!r.ok) throw new Error(`node token refused: ${r.code}`);++ const device = await domain.devices.bindNode(r.principal);++ conn.node = { principal: r.principal, reauthBy: now() + config.device.nodeReauthMs };++ attachDevice(conn, device);++ }++ /** `reauth`: a fresh node token for the same principal moves the deadline; anything else leaves it. */++ function onReauth(conn, msg) {++ const r = verifyNode(typeof msg.token === 'string' ? msg.token : '', { publicKey: keys.get(), issuer: config.network.issuer, audience: config.audience });++ if (!r.ok) return sendError(conn, 'bot.reauth_refused', r.reason);++ if (r.principal !== conn.node.principal) return sendError(conn, 'bot.reauth_refused', 'the token is for another machine');++ conn.node.reauthBy = now() + config.device.nodeReauthMs;++ return undefined;++ }++++ /**++ * `status` of a Network-paired device: device_kind, drivers, capabilities and agent_version are persisted++ * when they differ from the row. Any invalid one → bot.bad_frame and the row unchanged.++ */++ async function persistDeclared(conn, msg) {++ const d = conn.device;++ const has = (k) => msg[k] !== undefined;++ if ((has('device_kind') && !DEVICE_KINDS.has(msg.device_kind))++ || (has('drivers') && !(Array.isArray(msg.drivers) && msg.drivers.every((x) => typeof x === 'string')))++ || (has('capabilities') && !(msg.capabilities && typeof msg.capabilities === 'object' && !Array.isArray(msg.capabilities)))++ || (has('agent_version') && !(typeof msg.agent_version === 'string' && msg.agent_version.length <= MAX_AGENT_VERSION))) {++ return sendError(conn, 'bot.bad_frame', `device_kind must be one of ${[...DEVICE_KINDS].join('|')}, drivers an array of strings, capabilities an object, agent_version a string of at most ${MAX_AGENT_VERSION}`);++ }++ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);++ const change = {};++ if (has('device_kind') && msg.device_kind !== d.kind) change.kind = msg.device_kind;++ if (has('drivers') && !same(msg.drivers, json(d.drivers, []))) change.drivers = msg.drivers;++ if (has('capabilities') && !same(msg.capabilities, json(d.capabilities, {}))) change.capabilities = msg.capabilities;++ if (has('agent_version') && msg.agent_version !== (d.agent_version ?? null)) change.agent_version = msg.agent_version;++ if (!Object.keys(change).length) return undefined;++ const updated = await domain.devices.updateDeclared(d.id, change);++ if (updated) conn.device = updated;++ return undefined;++ }++function attachDevice(conn, device) {const existing = deviceConns.get(device.id);if (existing && existing.ws !== conn.ws) { try { existing.ws.close(4000, 'replaced by a newer connection'); } catch { /* gone */ } dropDevice(existing, { silent: true }); }@@@ -183,7 +229,11 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Datreturn sendFrame(conn, 'heartbeat_ack', ack);}case 'telemetry': return onTelemetry(conn, msg);-- case 'status': conn.status = msg; for (const r of deviceRobotIds(conn.device)) broadcast(r); return undefined;++ case 'status':++ conn.status = msg;++ for (const r of deviceRobotIds(conn.device)) broadcast(r);++ return conn.device.node_principal ? persistDeclared(conn, msg) : undefined;++ case 'reauth': if (conn.node) return onReauth(conn, msg); return sendError(conn, 'bot.unknown_message', `unknown type ${msg.type}`);case 'ack': case 'nack': return onAck(conn, msg);case 'estop_state': return onEstopState(conn, msg);default: return sendError(conn, 'bot.unknown_message', `unknown type ${msg.type}`);@@@ -446,6 +496,12 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Datfunction checkHeartbeats() {for (const conn of [...deviceConns.values()]) {++ if (conn.node && now() > conn.node.reauthBy) {++ // The node token lapsed with no valid reauth: the machine must authenticate again.++ try { conn.ws.close(4002, 'reauth required'); } catch { /* gone */ }++ dropDevice(conn);++ continue;++ }if (conn.online && now() - conn.lastHeartbeat > offlineAfterMs) {conn.online = false;domain.devices.setOnline(conn.device, false).catch((e) => log.warn(`[Bot] offline event: ${e.message}`));@@@ -2,7 +2,9 @@/*** Stand-ins for the services Bot talks to, each on a random port.*-- * startNetwork() JWKS and user/service JWTs (signUser, signService, newUser)++ * startNetwork() JWKS and user/service JWTs (signUser, signService, newUser); node principals as++ * GET /internal/node-principals/:id and POST …/:id/revoke answer them (addNode, signNode,++ * nodes, revokes), scoped to the calling service like Network's own routes* startEvents() POST /api/v1/events recording what Bot's outbox relays; GET /api/health*/const http = require('http');@@@ -33,6 +35,34 @@ async function startNetwork() {return jwt.sign({ sub: String(u.networkId || ++n), subject_id: u.subject, username: u.username, display_name: u.display_name || u.username, role: u.role || 'user' },privatePem, { algorithm: 'RS256', issuer, expiresIn: '1h' });}++ // Node principals: the view Network's principalView answers (server/registry/node-principals.js).++ // `unscoped: true` answers a principal to any service, so Bot's own paired_for check is exercised too.++ const nodes = new Map();++ const revokes = [];++ function addNode({ owner, ref, service = 'bot', status = 'active', name = 'garage-pi', unscoped = false } = {}) {++ const id = ids.newId('node');++ nodes.set(id, {++ unscoped,++ view: {++ principal: id, node_id: `n-${id.slice(4).toLowerCase()}`, name, owner: { kind: 'user', subject: owner },++ home_cell: 'cell-eu-1', status, paired_for: service ? { service, ref } : null,++ last_seen_at: null, created_at: new Date().toISOString(), revoked_at: status === 'revoked' ? new Date().toISOString() : null,++ },++ });++ return id;++ }++ function signNode(principal, { aud = ['openvibe.bot'], expSec = 300 } = {}) {++ const now = Math.floor(Date.now() / 1000);++ return serviceAuth.signServiceToken({ iss: issuer, sub: `node:${principal}`, actor_type: 'node', aud, cap: [], iat: now, exp: now + expSec, jti: `tok_${crypto.randomBytes(12).toString('hex')}` }, privatePem);++ }++ /** The calling service (svc:<id> → <id>) of a valid network.node.manage token for openvibe.network, or null. */++ function nodeManager(req) {++ const h = String(req.headers.authorization || '');++ const r = serviceAuth.verifyServiceToken(h.slice(7), { publicKey: publicPem, issuer, audience: 'openvibe.network' });++ if (!h.startsWith('Bearer ') || !r.ok || !(r.claims.cap || []).includes('network.node.manage')) return null;++ return r.claims.sub.replace(/^svc:/, '');++ }++function newUser(username) {return { subject: ids.newId('user'), username, display_name: username[0].toUpperCase() + username.slice(1) };}@@@ -46,11 +76,23 @@ async function startNetwork() {const cap = String(body.scope || '').split(/\s+/).filter(Boolean);return send(res, 200, { access_token: signService({ sub: `svc:${body.client_id}`, aud: [body.audience || 'openvibe.bot'], cap }), token_type: 'Bearer', expires_in: 300 });}++ const np = /^\/internal\/node-principals\/([^/]+)(\/revoke)?$/.exec(req.url);++ if (np && (req.method === (np[2] ? 'POST' : 'GET'))) {++ const service = nodeManager(req);++ if (!service) return send(res, 401, { code: 'token.invalid' });++ const n = nodes.get(decodeURIComponent(np[1]));++ if (!n || (!n.unscoped && (!n.view.paired_for || n.view.paired_for.service !== service))) return send(res, 404, { code: 'registry.unknown_node' });++ if (np[2]) {++ revokes.push(n.view.principal);++ if (n.view.status !== 'revoked') Object.assign(n.view, { status: 'revoked', revoked_at: new Date().toISOString() });++ }++ return send(res, 200, n.view);++ }send(res, 404, { error: 'not found' });});const url = await listen(server);issuer = url;-- return { url, publicPem, signService, signUser, newUser, close: () => new Promise((r) => server.close(r)) };++ return { url, publicPem, signService, signUser, newUser, addNode, signNode, nodes, revokes, close: () => new Promise((r) => server.close(r)) };}async function startEvents() {nnew file mode 100644@@@ -0,0 +1,199 @@++'use strict';++// Network-paired machines (T2 §9.2 B1, dual-accept): a node token binds a device from Network's record,++// POST /devices/bind issues its publish key without a credential, the /device socket speaks first exactly++// as for a credential, `status` persists what the machine declares, `reauth` keeps the socket, and revoke++// and rotate reach Network and the machine. Credential devices keep today's behaviour.++const assert = require('assert');++const crypto = require('crypto');++const { boot, check, done } = require('./helpers/app');++++const WHIP_BASE = 'https://whip.test/ingest';++const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');++++(async () => {++ const t = await boot({ env: { BOT_WHIP_BASE: WHIP_BASE } });++ const alex = t.network.newUser('alex');++ const sam = t.network.newUser('sam');++++ const bind = (token) => t.call('POST', '/api/v1/devices/bind', { token });++ const deviceRow = (id) => t.db.maybe('SELECT * FROM devices WHERE id = $1', [id]);++ const connect = (token) => t.ws('/device', { headers: { Authorization: `Bearer ${token}` } });++ /** A robot of `owner` and a machine Network paired for it. */++ async function paired(owner = alex, node = {}) {++ const { robot } = await t.robot(owner);++ return { robot, principal: t.network.addNode({ owner: owner.subject, ref: robot.id, ...node }) };++ }++++ await check('bind creates the device from Network\'s record with safe initial values, and answers no credential', async () => {++ const { robot, principal } = await paired();++ const r = await bind(t.network.signNode(principal));++ assert.strictEqual(r.status, 201, r.text);++ assert.strictEqual(r.headers.get('cache-control'), 'no-store');++ assert.deepStrictEqual(Object.keys(r.json).sort(), ['device_id', 'profile', 'publish_key', 'robot_id', 'whip_url']);++ assert.match(r.json.device_id, /^dev_/);++ assert.strictEqual(r.json.robot_id, robot.id);++ assert.strictEqual(r.json.profile.id, 'sim.rover');++ assert.ok(r.json.publish_key.length >= 40);++ assert.strictEqual(r.json.whip_url, `${WHIP_BASE}/${r.json.publish_key}`);++ const d = await deviceRow(r.json.device_id);++ assert.deepStrictEqual(d.robot_ids, [robot.id]);++ assert.strictEqual(d.name, 'garage-pi');++ assert.strictEqual(d.kind, 'onboard');++ assert.deepStrictEqual(d.drivers, []);++ assert.deepStrictEqual(d.capabilities, {});++ assert.strictEqual(d.agent_version, null);++ assert.strictEqual(d.credential_hash, null);++ assert.strictEqual(d.node_principal, principal);++ assert.strictEqual(d.publish_key_hash, sha256(r.json.publish_key));++ });++++ await check('bind twice answers the same device and a new publish key; the old key stops working', async () => {++ const { principal } = await paired();++ const first = await bind(t.network.signNode(principal));++ const second = await bind(t.network.signNode(principal));++ assert.strictEqual(second.status, 201, second.text);++ assert.strictEqual(second.json.device_id, first.json.device_id);++ assert.notStrictEqual(second.json.publish_key, first.json.publish_key);++ const d = await deviceRow(first.json.device_id);++ assert.strictEqual(d.publish_key_hash, sha256(second.json.publish_key));++ assert.notStrictEqual(d.publish_key_hash, sha256(first.json.publish_key));++ assert.strictEqual(Number((await t.db.maybe('SELECT count(*) AS n FROM devices WHERE node_principal = $1', [principal])).n), 1);++ });++++ await check('bind refuses anything but a node token for openvibe.bot', async () => {++ const { principal } = await paired();++ const network = await bind(t.network.signNode(principal, { aud: ['openvibe.network'] }));++ assert.strictEqual(network.status, 401, network.text);++ const user = await t.call('POST', '/api/v1/devices/bind', { user: alex });++ assert.strictEqual(user.status, 401, user.text);++ assert.strictEqual(user.json.code, 'bot.node_token_required');++ const service = await t.call('POST', '/api/v1/devices/bind', { cap: ['bot.*'] });++ assert.strictEqual(service.status, 401, service.text);++ });++++ await check('an upgrade with a node token and no prior bind gets hello then config first, and no paired frame', async () => {++ const { robot, principal } = await paired();++ const c = await connect(t.network.signNode(principal));++ const config = await c.waitFor((m) => m.type === 'config');++ assert.ok(config);++ assert.deepStrictEqual(c.messages.slice(0, 2).map((m) => m.type), ['hello', 'config']);++ assert.ok(!c.messages.some((m) => m.type === 'paired'));++ const hello = c.messages[0];++ assert.deepStrictEqual(hello.robot_ids, [robot.id]);++ const d = await deviceRow(hello.device_id);++ assert.strictEqual(d.node_principal, principal);++ assert.strictEqual(d.publish_key_hash, null, 'no publish key until the machine calls bind');++ assert.ok(t.hub.isOnline(d.id));++ // A later bind finds the same device.++ const b = await bind(t.network.signNode(principal));++ assert.strictEqual(b.json.device_id, d.id);++ c.close();++ });++++ await check('a status with device_kind, drivers, capabilities and agent_version updates the row; invalid values do not', async () => {++ const { principal } = await paired();++ const c = await connect(t.network.signNode(principal));++ const hello = await c.waitFor((m) => m.type === 'hello');++ c.send({ v: 1, seq: 1, ts: Date.now(), type: 'status', firmware: 'x', device_kind: 'bridge', drivers: ['pca9685', 'picamera2'], capabilities: { camera: true }, agent_version: '0.3.1' });++ let d;++ for (let i = 0; i < 40; i++) { d = await deviceRow(hello.device_id); if (d.kind === 'bridge') break; await t.wait(25); }++ assert.strictEqual(d.kind, 'bridge');++ assert.deepStrictEqual(d.drivers, ['pca9685', 'picamera2']);++ assert.deepStrictEqual(d.capabilities, { camera: true });++ assert.strictEqual(d.agent_version, '0.3.1');++ for (const bad of [{ device_kind: 'toaster' }, { drivers: 'pca9685' }, { drivers: [1] }, { agent_version: 'x'.repeat(41) }, { capabilities: [] }]) {++ const before = c.messages.filter((m) => m.type === 'error').length;++ c.send({ v: 1, seq: 2, ts: Date.now(), type: 'status', ...bad });++ await c.waitFor(() => c.messages.filter((m) => m.type === 'error').length > before);++ const err = c.messages.filter((m) => m.type === 'error').pop();++ assert.strictEqual(err.code, 'bot.bad_frame', JSON.stringify(bad));++ }++ const after = await deviceRow(hello.device_id);++ assert.strictEqual(after.kind, 'bridge');++ assert.deepStrictEqual(after.drivers, ['pca9685', 'picamera2']);++ assert.strictEqual(after.agent_version, '0.3.1');++ c.close();++ });++++ await check('a credential device\'s status stays memory-only', async () => {++ const { robot, pairing } = await t.robot(alex);++ const p = await t.call('POST', '/api/v1/pair', { token: null, body: { robot: robot.id, code: pairing.code, drivers: ['a'] } });++ const c = await connect(p.json.credential);++ await c.waitFor((m) => m.type === 'config');++ c.send({ v: 1, seq: 1, ts: Date.now(), type: 'status', device_kind: 'server', drivers: ['b'], agent_version: '9' });++ c.send({ v: 1, seq: 2, ts: Date.now(), type: 'reauth', token: 'x' });++ const err = await c.waitFor((m) => m.type === 'error');++ assert.strictEqual(err.code, 'bot.unknown_message', 'reauth is not a credential device\'s frame');++ const d = await deviceRow(p.json.device_id);++ assert.strictEqual(d.kind, 'onboard');++ assert.deepStrictEqual(d.drivers, ['a']);++ c.close();++ });++++ await check('a principal paired for another owner\'s robot, revoked, or not for Bot → 403 and close 4002', async () => {++ const { robot: samsRobot } = await t.robot(sam);++ const cases = {++ 'another owner\'s robot': t.network.addNode({ owner: alex.subject, ref: samsRobot.id }),++ revoked: (await paired(alex, { status: 'revoked' })).principal,++ 'paired for another service (Network answers 404)': (await paired(alex, { service: 'live' })).principal,++ 'paired for another service (seen)': (await paired(alex, { service: 'live', unscoped: true })).principal,++ 'unknown on Network': 'nod_01ARZ3NDEKTSV4RRFFQ69G5FAV',++ };++ for (const [name, principal] of Object.entries(cases)) {++ const r = await bind(t.network.signNode(principal));++ assert.strictEqual(r.status, 403, `${name}: ${r.text}`);++ assert.strictEqual(r.json.code, 'bot.node_not_bound', name);++ const c = await connect(t.network.signNode(principal));++ assert.strictEqual(await c.waitForClose(), 4002, name);++ assert.ok(!c.messages.some((m) => m.type === 'hello'), name);++ assert.strictEqual(await t.db.maybe('SELECT id FROM devices WHERE node_principal = $1', [principal]), null, name);++ }++ });++++ await check('no valid reauth within 330 s of the last token → close 4002; a valid one keeps the socket', async () => {++ const { principal } = await paired();++ const other = (await paired()).principal;++ const c = await connect(t.network.signNode(principal));++ await c.waitFor((m) => m.type === 'config');++ t.clock.offset += 200 * 1000;++ c.send({ v: 1, seq: 1, ts: Date.now(), type: 'reauth', token: t.network.signNode(other) });++ assert.strictEqual((await c.waitFor((m) => m.type === 'error')).code, 'bot.reauth_refused', 'another machine\'s token is no reauth');++ c.send({ v: 1, seq: 2, ts: Date.now(), type: 'reauth', token: t.network.signNode(principal) });++ await t.wait(100);++ t.clock.offset += 200 * 1000; // 400 s after connect, 200 s after the reauth++ await t.wait(400);++ assert.ok(!c.ended, 'a valid reauth moved the deadline');++ t.clock.offset += 131 * 1000; // 331 s after the reauth++ assert.strictEqual(await c.waitForClose(), 4002);++ t.clock.offset = 0;++ });++++ await check('revoke of a Network-paired device revokes the principal on Network and closes the socket; it never binds again', async () => {++ const { principal } = await paired();++ const c = await connect(t.network.signNode(principal));++ const hello = await c.waitFor((m) => m.type === 'hello');++ const r = await t.call('POST', `/api/v1/devices/${hello.device_id}/revoke`, { user: alex });++ assert.strictEqual(r.status, 200, r.text);++ assert.ok(t.network.revokes.includes(principal));++ assert.strictEqual(await c.waitForClose(), 4003);++ const again = await bind(t.network.signNode(principal));++ assert.strictEqual(again.status, 403);++ assert.strictEqual(again.json.code, 'bot.node_not_bound');++ });++++ await check('rotate of a Network-paired device asks the machine to rotate and answers no credential', async () => {++ const { principal } = await paired();++ const c = await connect(t.network.signNode(principal));++ const hello = await c.waitFor((m) => m.type === 'hello');++ const r = await t.call('POST', `/api/v1/devices/${hello.device_id}/rotate`, { user: alex });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.json.sent, true);++ assert.ok(!('credential' in r.json) && !('publish_key' in r.json), r.text);++ assert.ok(await c.waitFor((m) => m.type === 'rotate'));++ assert.strictEqual((await deviceRow(hello.device_id)).credential_hash, null);++ c.close();++ });++++ await t.close();++ done();++})();
@@ -44,6 +44,7 @@ OV_OAUTH_CLIENT_SECRET=
@@ -44,6 +44,7 @@ OV_OAUTH_CLIENT_SECRET=
#BOT_OFFLINE_MISSES=2
#BOT_OFFLINE_MISSES=2
#BOT_OFFLINE_GRACE_MS=3000
#BOT_OFFLINE_GRACE_MS=3000
#BOT_ROTATE_GRACE_MS=60000
#BOT_ROTATE_GRACE_MS=60000
+#BOT_NODE_REAUTH_MS=330000
# ── Control ─────────────────────────────────────────────────
# ── Control ─────────────────────────────────────────────────
#BOT_MAX_COMMAND_MS=300
#BOT_MAX_COMMAND_MS=300
@@ -29,6 +29,17 @@ header; such a socket may only send `pair` until it is answered (anything else
@@ -29,6 +29,17 @@ 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.
+**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
+Network's record: the robot the machine was paired for, which must be one of the principal's owner's
+robots, and the principal must be `active`), then speaks first exactly as for a credential — `hello`,
+`config` — with **no** `paired` frame. A refused node token, or a principal Bot will not bind (revoked,
+paired for another service, for someone else's robot, or revoked here), closes the socket with **4002**.
+A node token lives 300 s: the machine sends `reauth` with a fresh one before it expires; with no valid
+`reauth` within 330 s of the last token Bot closes the socket with **4002**. Any other Bearer is a device
+credential, as above.
+
Frames sent while authentication is still in progress — right after an upgrade that carries the
Frames sent while authentication is still in progress — right after an upgrade that carries the
header, or after `pair` until it is answered — are **kept** and handled in arrival order once it
header, or after `pair` until it is answered — are **kept** and handled in arrival order once it
completes (after `hello` and `config`), so a device may send its first `status` and `estop_state`
completes (after `hello` and `config`), so a device may send its first `status` and `estop_state`
@@ -46,6 +57,7 @@ At most 64 frames wait; any more are answered `error` `bot.not_ready`.
@@ -46,6 +57,7 @@ At most 64 frames wait; any more are answered `error` `bot.not_ready`.
| `command` | `id, kind, value, deadline_ms, operator{subject,role}, robot_id` | an operator's command passed the gate |
| `command` | `id, kind, value, deadline_ms, operator{subject,role}, robot_id` | an operator's command passed the gate |
| `estop` | `latched, by, at` | the e-stop latched or the owner cleared it |
| `estop` | `latched, by, at` | the e-stop latched or the owner cleared it |
| `heartbeat_ack` | `echo, t, server_time` | the answer to `heartbeat`; `echo` is the heartbeat's `t` (or `null`), so the device computes RTT, and `t` repeats a finite `t` (device ms) unchanged — the field OpenVibe.Node reads. The envelope `seq` is Bot's own counter and never echoes the device's |
| `heartbeat_ack` | `echo, t, server_time` | the answer to `heartbeat`; `echo` is the heartbeat's `t` (or `null`), so the device computes RTT, and `t` repeats a finite `t` (device ms) unchanged — the field OpenVibe.Node reads. The envelope `seq` is Bot's own counter and never echoes the device's |
+| `rotate` | — | the owner forced a rotation of a Network-paired machine (`POST /devices/:id/rotate`); the machine rotates its credential with Network itself |
| `error` | `code, detail` | a frame the server refused |
| `error` | `code, detail` | a frame the server refused |
### Device → server
### Device → server
@@ -55,7 +67,8 @@ At most 64 frames wait; any more are answered `error` `bot.not_ready`.
@@ -55,7 +67,8 @@ At most 64 frames wait; any more are answered `error` `bot.not_ready`.
| `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 |
| `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` | on connect and on change |
+| `status` | `firmware, capabilities, faults[], estop_latched, device_kind?, drivers[]?, agent_version?` | on connect and on change. See below |
+| `reauth` | `token` | a Network-paired machine only: a fresh node token for the same principal, before the last one expires (300 s); a refused one is answered `error` `bot.reauth_refused` and moves nothing |
| `ack` | `id` | a command ran |
| `ack` | `id` | a command ran |
| `nack` | `id, fault_code` | a command was refused on the device |
| `nack` | `id, fault_code` | a command was refused on the device |
| `estop_state` | `latched, by, at, robot_id?` | the device's own latch, a **report**; `robot_id` omitted = every robot the device serves. See below |
| `estop_state` | `latched, by, at, robot_id?` | the device's own latch, a **report**; `robot_id` omitted = every robot the device serves. See below |
@@ -67,6 +80,13 @@ kinds its robot's profile declares (§1.1), with values in the shapes and ranges
@@ -67,6 +80,13 @@ kinds its robot's profile declares (§1.1), with values in the shapes and ranges
on every disconnect or crash path, without asking the network. `halt` (value `{}`, no deadline) stops
on every disconnect or crash path, without asking the network. `halt` (value `{}`, no deadline) stops
everything at once and passes the gate even while the e-stop is latched.
everything at once and passes the gate even while the e-stop is latched.
+`status` of a Network-paired machine also says what it is: `device_kind` (`onboard` \| `bridge` \|
+`server`), `drivers` (an array of strings), `capabilities` (an object) and `agent_version` (a string of at
+most 40 characters), each optional. Bot stores them on the device row when they differ from it (the row
+starts as `onboard`, no drivers, `{}`, no version); if any of them is invalid the frame is answered `error`
+`bot.bad_frame` and the row is left unchanged. For a credential device these fields are not stored: what
+it declared at pairing stays.
+
`config.limits` are the **effective** limits — the owner's (`PATCH /robots/:id` `limits`) clamped by the
`config.limits` are the **effective** limits — the owner's (`PATCH /robots/:id` `limits`) clamped by the
profile's — as `{ max_speed, max_turn, max_command_ms, heartbeat_ms }`, the same numbers the gate clamps
profile's — as `{ max_speed, max_turn, max_command_ms, heartbeat_ms }`, the same numbers the gate clamps
with; `allowed_commands` is what the owner may send: the profile's kinds, cut by the owner's allowlist,
with; `allowed_commands` is what the owner may send: the profile's kinds, cut by the owner's allowlist,
@@ -296,8 +316,9 @@ shown; a service acts for `X-OV-Subject` / the body's `owner`.
@@ -296,8 +316,9 @@ shown; a service acts for `X-OV-Subject` / the body's `owner`.
| `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[] }` |
| `GET /robots/:id/devices` | member, or `bot.robot.read` | `{ devices[] }` (no hashes, `online`) |
| `GET /robots/:id/devices` | member, or `bot.robot.read` | `{ devices[] }` (no hashes, `online`) |
-| `POST /devices/:id/rotate` | the robot's owner, `bot.device.connect` | `{ device, credential, publish_key, whip_url }` (once) |
-| `POST /devices/:id/revoke` | the robot's owner, `bot.device.connect` | `{ device }`; the socket closes at once |
+| `POST /devices/:id/rotate` | the robot's owner, `bot.device.connect` | `{ device, credential, publish_key, whip_url }` (once); for a Network-paired machine `{ device, sent }` — the machine is sent `rotate`, no credential is answered |
+| `POST /devices/:id/revoke` | the robot's owner, `bot.device.connect` | `{ device }`; the socket closes at once. A Network-paired machine's principal is revoked on Network too (`503 bot.network_unavailable` if Network did not answer: revoked here, retry) and never binds again |
+| `POST /devices/bind` | a Network node token (audience `openvibe.bot`), no body | `201 { device_id, publish_key, whip_url, robot_id, profile }` — `POST /pair`'s answer without `credential`; again → the same device and a new publish key (the old one stops working). Refused: `401 bot.node_token_required`, `403 bot.node_not_bound` |
| `POST /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) |
new file mode 100644
new file mode 100644
@@ -0,0 +1,12 @@
@@ -0,0 +1,12 @@
+-- phase: expand
+--
+-- The node principal (T2 lane B, OpenVibe.Network docs/t2-cells-and-node-principal.md §9.2 B1): a device may
+-- be bound to a Network node principal (nod_…) instead of holding a Bot credential. Dual-accept, additive only:
+-- every credential device keeps working unchanged; a Network-paired device has node_principal and no
+-- credential_hash. The existing devices_credential unique index already ignores revoked rows, and NULLs never
+-- collide in it.
+
+ALTER TABLE devices ADD COLUMN node_principal text; -- nod_<ULID>, Network's principal; NULL for a credential device
+-- bindNode: the live device of one principal (at most one).
+CREATE UNIQUE INDEX devices_node_principal ON devices (node_principal) WHERE node_principal IS NOT NULL AND revoked_at IS NULL;
+ALTER TABLE devices ALTER COLUMN credential_hash DROP NOT NULL;
@@ -9,7 +9,7 @@ const { createActorLimiter, createValkeyLimitStore, defaultActor } = require('op
@@ -9,7 +9,7 @@ const { createActorLimiter, createValkeyLimitStore, defaultActor } = require('op
function actor(req) {
function actor(req) {
const p = req.principal;
const p = req.principal;
- if (p && p.kind === 'service' && p.sub) return p.sub;
+ if (p && (p.kind === 'service' || p.kind === 'node') && p.sub) return p.sub;
if (p && p.kind === 'user' && p.subject) return `user:${p.subject}`;
if (p && p.kind === 'user' && p.subject) return `user:${p.subject}`;
return defaultActor(req);
return defaultActor(req);
}
}
@@ -5,6 +5,8 @@
@@ -5,6 +5,8 @@
*
*
* { kind: 'service', sub: 'svc:live', cap: [...] } a Network client-credentials token for audience
* { kind: 'service', sub: 'svc:live', cap: [...] } a Network client-credentials token for audience
* openvibe.bot; each route checks ONE capability
* openvibe.bot; each route checks ONE capability
+ * { kind: 'node', principal: 'nod_…', sub: 'node:nod_…' } a Network node token (actor_type node, audience
+ * openvibe.bot): a machine paired for Bot; it may only bind
* { kind: 'user', subject: 'usr_…', username, name, avatar, role }
* { kind: 'user', subject: 'usr_…', username, name, avatar, role }
* { kind: 'anonymous' }
* { kind: 'anonymous' }
*
*
@@ -12,7 +14,8 @@
@@ -12,7 +14,8 @@
*/
*/
const { serviceAuth, capabilities, http, ids } = require('openvibe-contracts');
const { serviceAuth, capabilities, http, ids } = require('openvibe-contracts');
-const PRINCIPAL_SUB = /^(svc|app|mod):/;
+const PRINCIPAL_SUB = /^(svc|app|mod|node):/;
+const NODE_SUB = /^node:(nod_[0-9A-HJKMNP-TV-Z]{26})$/;
const ANON = Object.freeze({ kind: 'anonymous' });
const ANON = Object.freeze({ kind: 'anonymous' });
function decodePayload(token) {
function decodePayload(token) {
@@ -42,6 +45,25 @@ function verifyService(token, { publicKey, issuer, audience }) {
@@ -42,6 +45,25 @@ function verifyService(token, { publicKey, issuer, audience }) {
return { ok: true, claims: r.claims };
return { ok: true, claims: r.claims };
}
}
+/** A Bearer that claims to be a node token (actor_type node); verifyNode decides whether it is one. */
+function isNodeToken(token) {
+ const payload = decodePayload(token);
+ return !!payload && payload.actor_type === 'node';
+}
+
+/**
+ * Verify a node token: signed by Network, audience openvibe.bot, actor_type node, sub node:nod_…
+ * Returns { ok, principal: 'nod_…', claims } or { ok:false, code, reason }.
+ */
+function verifyNode(token, { publicKey, issuer, audience }) {
+ if (!publicKey) return { ok: false, code: 'identity.unavailable', reason: 'the Network signing key is not loaded yet' };
+ const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer, audience });
+ if (!r.ok) return { ok: false, code: r.code, reason: r.reason };
+ const m = NODE_SUB.exec(String(r.claims.sub || ''));
+ if (r.claims.actor_type !== 'node' || !m) return { ok: false, code: 'token.invalid', reason: 'not a node token' };
+ return { ok: true, principal: m[1], claims: r.claims };
+}
+
function createApiAuth({ config, keys, userAuth }) {
function createApiAuth({ config, keys, userAuth }) {
function resolve(req) {
function resolve(req) {
const header = String(req.headers.authorization || '');
const header = String(req.headers.authorization || '');
@@ -50,6 +72,11 @@ function createApiAuth({ config, keys, userAuth }) {
@@ -50,6 +72,11 @@ function createApiAuth({ config, keys, userAuth }) {
const publicKey = keys.get();
const publicKey = keys.get();
if (!publicKey) return { error: [503, 'identity.unavailable', 'the Network signing key is not loaded yet'] };
if (!publicKey) return { error: [503, 'identity.unavailable', 'the Network signing key is not loaded yet'] };
const payload = decodePayload(token);
const payload = decodePayload(token);
+ if (payload && payload.actor_type === 'node') {
+ const r = verifyNode(token, { publicKey, issuer: config.network.issuer, audience: config.audience });
+ if (!r.ok) return { error: [401, r.code, r.reason] };
+ return { principal: { kind: 'node', principal: r.principal, sub: r.claims.sub, jti: r.claims.jti } };
+ }
if (payload && typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) {
if (payload && typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) {
const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.network.issuer, audience: config.audience });
const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.network.issuer, audience: config.audience });
if (!r.ok) return { error: [401, r.code, r.reason] };
if (!r.ok) return { error: [401, r.code, r.reason] };
@@ -74,4 +101,4 @@ function createApiAuth({ config, keys, userAuth }) {
@@ -74,4 +101,4 @@ function createApiAuth({ config, keys, userAuth }) {
return { middleware, resolve, granted };
return { middleware, resolve, granted };
}
}
-module.exports = { createApiAuth, userPrincipal, verifyService, decodePayload, PRINCIPAL_SUB };
+module.exports = { createApiAuth, userPrincipal, verifyService, verifyNode, isNodeToken, decodePayload, PRINCIPAL_SUB };
@@ -19,6 +19,7 @@
@@ -19,6 +19,7 @@
* | DELETE /robots/:id/operators/:subject | bot.robot.manage | owner |
* | DELETE /robots/:id/operators/:subject | bot.robot.manage | owner |
* | GET /robots/:id/devices | bot.robot.read | a member |
* | GET /robots/:id/devices | bot.robot.read | a member |
* | POST /devices/:id/rotate, /revoke | bot.device.connect | the robot's owner |
* | POST /devices/:id/rotate, /revoke | bot.device.connect | the robot's owner |
+ * | POST /devices/bind | — (a node token) | a machine paired for Bot on Network |
* | POST /robots/:id/estop | bot.robot.control | owner or operator |
* | POST /robots/:id/estop | bot.robot.control | owner or operator |
* | 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) |
@@ -148,6 +149,12 @@ function v1Router({ domain, apiAuth, limits, hub }) {
@@ -148,6 +149,12 @@ function v1Router({ domain, apiAuth, limits, hub }) {
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]);
+ if (d.node_principal) {
+ // A Network-paired machine rotates its credential with Network itself; Bot only asks it to.
+ if (d.revoked_at) fail(404, 'bot.device_not_found', 'no live device');
+ const sent = hub.sendToDevice(d.id, { type: 'rotate' });
+ return res.json({ device: domain.present.device(d), sent });
+ }
const rotated = await domain.devices.rotate(req.params.id);
const rotated = await domain.devices.rotate(req.params.id);
res.json({ device: domain.present.device(rotated.device), credential: rotated.credential, publish_key: rotated.publish_key, ...(rotated.whip_url ? { whip_url: rotated.whip_url } : {}) });
res.json({ device: domain.present.device(rotated.device), credential: rotated.credential, publish_key: rotated.publish_key, ...(rotated.whip_url ? { whip_url: rotated.whip_url } : {}) });
}));
}));
@@ -156,7 +163,11 @@ function v1Router({ domain, apiAuth, limits, hub }) {
@@ -156,7 +163,11 @@ function v1Router({ domain, apiAuth, limits, hub }) {
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]);
const revoked = await domain.devices.revoke(req.params.id);
const revoked = await domain.devices.revoke(req.params.id);
+ // A Network-paired machine is revoked on Network too (Bot never binds that principal again either way).
+ let networkError = null;
+ if (d.node_principal) await domain.devices.revokeNode(d.node_principal).catch((e) => { networkError = e; });
hub.closeDevice(req.params.id, 'revoked'); // revocation disconnects the device at once
hub.closeDevice(req.params.id, 'revoked'); // revocation disconnects the device at once
+ if (networkError) throw networkError; // revoked here; the owner retries for Network
res.json({ device: domain.present.device(revoked) });
res.json({ device: domain.present.device(revoked) });
}));
}));
@@ -199,6 +210,17 @@ function v1Router({ domain, apiAuth, limits, hub }) {
@@ -199,6 +210,17 @@ function v1Router({ domain, apiAuth, limits, hub }) {
res.status(201).json({ device_id: result.device.id, credential: result.credential, publish_key: result.publish_key, ...(result.whip_url ? { whip_url: result.whip_url } : {}), robot_id: json(result.device.robot_ids, [])[0], profile: result.profile });
res.status(201).json({ device_id: result.device.id, credential: result.credential, publish_key: result.publish_key, ...(result.whip_url ? { whip_url: result.whip_url } : {}), robot_id: json(result.device.robot_ids, [])[0], profile: result.profile });
}));
}));
+ // ── Bootstrap of a Network-paired machine (its node token, once after pairing) ────────────────
+ // Binds the machine to its robot and issues its WHIP publish key; calling again re-issues the key (the
+ // old one stops working) for the same device. POST /pair's answer without a credential.
+ r.post('/devices/bind', pair, wrap(async (req, res) => {
+ if (req.principal.kind !== 'node') fail(401, 'bot.node_token_required', 'a Network node token (audience openvibe.bot) is required');
+ const device = await domain.devices.bindNode(req.principal.principal);
+ const issued = await domain.devices.issuePublishKey(device.id);
+ res.setHeader('Cache-Control', 'no-store');
+ res.status(201).json({ device_id: issued.device.id, publish_key: issued.publish_key, ...(issued.whip_url ? { whip_url: issued.whip_url } : {}), robot_id: json(issued.device.robot_ids, [])[0], profile: issued.profile });
+ }));
+
return r;
return r;
}
}
@@ -9,7 +9,7 @@
@@ -9,7 +9,7 @@
* /auth/* Network SSO session for people (the control WS accepts the ov_token cookie)
* /auth/* Network SSO session for people (the control WS accepts the ov_token cookie)
* GET / a one-line text placeholder; the panel and the site are designed separately
* GET / a one-line text placeholder; the panel and the site are designed separately
*
*
- * createApp({ config, db, valkey, registry, keys, hub, outbox, now, fetchImpl, log }) — everything
+ * createApp({ config, db, valkey, registry, keys, hub, outbox, nodes, now, fetchImpl, log }) — everything
* injectable. `db` is an openvibe-sdk/db handle with the schema migrated; `hub` the realtime WebSocket
* injectable. `db` is an openvibe-sdk/db handle with the schema migrated; `hub` the realtime WebSocket
* hub (server/realtime.js), whose handleUpgrade the caller attaches to the HTTP server.
* hub (server/realtime.js), whose handleUpgrade the caller attaches to the HTTP server.
*/
*/
@@ -20,7 +20,7 @@ const { http } = require('openvibe-contracts');
@@ -20,7 +20,7 @@ const { http } = require('openvibe-contracts');
const { isLoopbackDirect } = require('openvibe-shared/metrics');
const { isLoopbackDirect } = require('openvibe-shared/metrics');
const { loadConfig } = require('./config');
const { loadConfig } = require('./config');
const { openDb } = require('./db');
const { openDb } = require('./db');
-const { createKeyProvider, createUserAuth } = require('./network');
+const { createKeyProvider, createUserAuth, createNodePrincipals } = require('./network');
const { createBotOutbox } = require('./events/outbox');
const { createBotOutbox } = require('./events/outbox');
const { createDomain } = require('./domain');
const { createDomain } = require('./domain');
const { createRealtime } = require('./realtime');
const { createRealtime } = require('./realtime');
@@ -45,7 +45,8 @@ function createApp(opts = {}) {
@@ -45,7 +45,8 @@ function createApp(opts = {}) {
const userAuth = createUserAuth(config, keys);
const userAuth = createUserAuth(config, keys);
const outbox = opts.outbox || createBotOutbox({ db, config, fetchImpl: opts.eventsFetch, now, log });
const outbox = opts.outbox || createBotOutbox({ db, config, fetchImpl: opts.eventsFetch, now, log });
const hub = opts.hub || createRealtime({ config, keys, userAuth, log, now });
const hub = opts.hub || createRealtime({ config, keys, userAuth, log, now });
- const domain = createDomain({ db, config, outbox, link: hub, now, log });
+ const nodes = opts.nodes || createNodePrincipals(config, { fetchImpl });
+ const domain = createDomain({ db, config, outbox, link: hub, nodes, now, log });
hub.bindDomain(domain);
hub.bindDomain(domain);
const apiAuth = createApiAuth({ config, keys, userAuth });
const apiAuth = createApiAuth({ config, keys, userAuth });
const release = require('openvibe-shared/release').createRelease({ service: 'bot', root: path.join(__dirname, '..') });
const release = require('openvibe-shared/release').createRelease({ service: 'bot', root: path.join(__dirname, '..') });
@@ -76,6 +76,9 @@ function loadConfig(env = process.env) {
@@ -76,6 +76,9 @@ function loadConfig(env = process.env) {
offlineMisses: int(env.BOT_OFFLINE_MISSES, 2),
offlineMisses: int(env.BOT_OFFLINE_MISSES, 2),
offlineGraceMs: int(env.BOT_OFFLINE_GRACE_MS, 3000),
offlineGraceMs: int(env.BOT_OFFLINE_GRACE_MS, 3000),
rotateGraceMs: int(env.BOT_ROTATE_GRACE_MS, 60 * 1000),
rotateGraceMs: int(env.BOT_ROTATE_GRACE_MS, 60 * 1000),
+ // A Network-paired machine's node token lives 300 s; with no valid `reauth` within 330 s of the
+ // last token the socket closes 4002.
+ nodeReauthMs: int(env.BOT_NODE_REAUTH_MS, 330 * 1000),
},
},
// Control gate (ADR-043 decision 5/6/8). maxCommandMs is the absolute deadline cap; the queue is
// Control gate (ADR-043 decision 5/6/8). maxCommandMs is the absolute deadline cap; the queue is
// the only way a stranger drives a robot.
// the only way a stranger drives a robot.
@@ -15,7 +15,7 @@
@@ -15,7 +15,7 @@
* stop is never refused to someone who may drive. Every decision — allowed or refused — is audited.
* stop is never refused to someone who may drive. Every decision — allowed or refused — is audited.
*/
*/
const {
const {
- BotError, fail, prefixedId, iso, token, hashSecret, secretEquals, json, text, storable,
+ BotError, fail, prefixedId, iso, token, hashSecret, secretEquals, json, text, storable, isRobotId,
} = require('../util');
} = require('../util');
const { getProfile } = require('../profiles');
const { getProfile } = require('../profiles');
const { ENVELOPE } = require('../events/outbox');
const { ENVELOPE } = require('../events/outbox');
@@ -59,7 +59,7 @@ function noteToHz(note) {
@@ -59,7 +59,7 @@ function noteToHz(note) {
return 440 * 2 ** ((12 * (Number(m[3]) + 1) + semis - 69) / 12);
return 440 * 2 ** ((12 * (Number(m[3]) + 1) + semis - 69) / 12);
}
}
-function createDomain({ db, config, outbox, link = null, now = () => Date.now(), log = console }) {
+function createDomain({ db, config, outbox, link = null, nodes = null, now = () => Date.now(), log = console }) {
// ── Presenters ────────────────────────────────────────────────────────────────────────────────
// ── Presenters ────────────────────────────────────────────────────────────────────────────────
const presentRobot = (r) => (r ? {
const presentRobot = (r) => (r ? {
id: r.id, name: r.name, profile_id: r.profile_id, profile_version: r.profile_version,
id: r.id, name: r.name, profile_id: r.profile_id, profile_version: r.profile_version,
@@ -264,6 +264,68 @@ function createDomain({ db, config, outbox, link = null, now = () => Date.now(),
@@ -264,6 +264,68 @@ function createDomain({ db, config, outbox, link = null, now = () => Date.now(),
const updated = await db.maybe('UPDATE devices SET revoked_at = $2, credential_prev_hash = NULL, prev_valid_until = NULL, updated_at = $2 WHERE id = $1 RETURNING *', [id, at]);
const updated = await db.maybe('UPDATE devices SET revoked_at = $2, credential_prev_hash = NULL, prev_valid_until = NULL, updated_at = $2 WHERE id = $1 RETURNING *', [id, at]);
return updated;
return updated;
}
}
+
+ // ── Network-paired devices (node principals, T2 §9.2 B1) ──────────────────────────────────────
+ /**
+ * The live device bound to Network node principal `principalId`, creating it on first use. Idempotent:
+ * a live row is returned as it is. Otherwise Network's record decides (GET /internal/node-principals/:id
+ * with Bot's service token): the principal must be active, paired for Bot, for a robot of the principal's
+ * own owner; anything else is 403 bot.node_not_bound. A principal whose device the owner revoked here
+ * never binds again. The row starts from Network's record and safe values only; what the device declares
+ * arrives later in its `status` frames.
+ */
+ async function bindNode(principalId) {
+ const known = await db.maybe(
+ 'SELECT * FROM devices WHERE node_principal = $1 ORDER BY (revoked_at IS NULL) DESC, created_at DESC LIMIT 1', [principalId]);
+ if (known && !known.revoked_at) return known;
+ const refuse = (why) => fail(403, 'bot.node_not_bound', why);
+ if (known) refuse('this machine was revoked on Bot; pair it again');
+ if (!nodes) refuse('Network-paired machines are not configured here');
+ const p = await nodes.get(principalId);
+ if (!p || p.principal !== principalId) refuse('Network knows no such machine paired for Bot');
+ if (p.status !== 'active') refuse(`the machine is ${p.status} on Network`);
+ if (!p.paired_for || p.paired_for.service !== 'bot') refuse('the machine was not paired for Bot');
+ const robot = isRobotId(p.paired_for.ref) ? await getRobot(p.paired_for.ref) : null;
+ if (!robot || !p.owner || p.owner.kind !== 'user' || p.owner.subject !== robot.owner_subject) refuse("the machine was not paired for one of its owner's robots");
+ const at = iso(now());
+ const name = typeof p.name === 'string' ? storable(p.name).trim().slice(0, 80) || null : null;
+ // Two first connections at once: the unique index lets one insert win, and the other reads it.
+ const inserted = await db.maybe(
+ `INSERT INTO devices (id, robot_ids, name, kind, agent_version, drivers, capabilities, credential_hash, node_principal, created_at, updated_at)
+ VALUES ($1, $2::jsonb, $3, 'onboard', NULL, '[]'::jsonb, '{}'::jsonb, NULL, $4, $5, $5)
+ ON CONFLICT (node_principal) WHERE node_principal IS NOT NULL AND revoked_at IS NULL DO NOTHING RETURNING *`,
+ [prefixedId('dev', now()), JSON.stringify([robot.id]), name, principalId, at]);
+ return inserted || db.maybe('SELECT * FROM devices WHERE node_principal = $1 AND revoked_at IS NULL', [principalId]);
+ }
+ /**
+ * Issue (or re-issue) a device's WHIP publish key: the new key replaces the old one, which stops working.
+ * → { device, publish_key, whip_url?, profile }, the key shown this once.
+ */
+ async function issuePublishKey(id) {
+ const publishKey = token(32);
+ const device = await db.maybe('UPDATE devices SET publish_key_hash = $2, updated_at = $3 WHERE id = $1 AND revoked_at IS NULL RETURNING *',
+ [id, hashSecret(publishKey), iso(now())]);
+ if (!device) fail(404, 'bot.device_not_found', 'no live device');
+ const robot = await getRobot(json(device.robot_ids, [])[0]);
+ const profile = robot ? await getProfile(db, robot.profile_id) : null;
+ const whUrl = whipUrl(publishKey);
+ return { device, publish_key: publishKey, ...(whUrl ? { whip_url: whUrl } : {}), profile: profile ? profile.profile : null };
+ }
+ /** Persist what a Network-paired device declares (kind, drivers, capabilities, agent_version): only the given fields. */
+ async function updateDeclared(id, { kind, drivers, capabilities, agent_version: agentVersion }) {
+ return db.maybe(
+ `UPDATE devices SET kind = COALESCE($2, kind), drivers = COALESCE($3::jsonb, drivers), capabilities = COALESCE($4::jsonb, capabilities),
+ agent_version = CASE WHEN $5 THEN $6 ELSE agent_version END, updated_at = $7
+ WHERE id = $1 AND revoked_at IS NULL RETURNING *`,
+ [id, kind ?? null, drivers ? JSON.stringify(drivers) : null, capabilities ? JSON.stringify(capabilities) : null,
+ agentVersion !== undefined, agentVersion ?? null, iso(now())]);
+ }
+ /** Revoke a Network-paired device's principal on Network too (null: Network no longer knows it). */
+ async function revokeNode(principalId) {
+ if (!nodes) fail(503, 'bot.network_unavailable', 'Network-paired machines are not configured here');
+ return nodes.revoke(principalId);
+ }
+
async function touchSeen(id) { await db.query('UPDATE devices SET last_seen = $2 WHERE id = $1', [id, iso(now())]); }
async function touchSeen(id) { await db.query('UPDATE devices SET last_seen = $2 WHERE id = $1', [id, iso(now())]); }
async function setOnline(device, online) {
async function setOnline(device, online) {
@@ -591,7 +653,7 @@ function createDomain({ db, config, outbox, link = null, now = () => Date.now(),
@@ -591,7 +653,7 @@ function createDomain({ db, config, outbox, link = null, now = () => Date.now(),
robots: { create: createRobot, list: listRobots, get: getRobot, update: updateRobot, remove: removeRobot },
robots: { create: createRobot, list: listRobots, get: getRobot, update: updateRobot, remove: removeRobot },
members: { roleOf, add: addOperator, remove: removeOperator, list: listOperators },
members: { roleOf, add: addOperator, remove: removeOperator, list: listOperators },
pairing: { create: createPairingCode, redeem, prune: prunePairingCodes, installerCommand, whipUrl },
pairing: { create: createPairingCode, redeem, prune: prunePairingCodes, installerCommand, whipUrl },
- devices: { byCredential, get: getDevice, listForRobot: listDevicesForRobot, rotate: rotateDevice, revoke: revokeDevice, touchSeen, setOnline },
+ devices: { byCredential, bindNode, issuePublishKey, updateDeclared, revokeNode, get: getDevice, listForRobot: listDevicesForRobot, rotate: rotateDevice, revoke: revokeDevice, touchSeen, setOnline },
estop: { set: setEstop, clear: clearEstop },
estop: { set: setEstop, clear: clearEstop },
queue: { join: joinQueue, state: queueState, currentTurn, consume: consumeTurn, sweep: sweepQueues },
queue: { join: joinQueue, state: queueState, currentTurn, consume: consumeTurn, sweep: sweepQueues },
audit: { record: auditCommand, list: listAudit, listPage: listAuditPage, prune: pruneAudit },
audit: { record: auditCommand, list: listAudit, listPage: listAuditPage, prune: pruneAudit },
@@ -6,10 +6,14 @@
@@ -6,10 +6,14 @@
* 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).
*/
*/
const crypto = require('crypto');
const crypto = require('crypto');
const jwt = require('jsonwebtoken');
const jwt = require('jsonwebtoken');
const { OpenVibeAuthClient } = require('openvibe-shared/auth-client');
const { OpenVibeAuthClient } = require('openvibe-shared/auth-client');
+const { serviceAuth } = require('openvibe-contracts');
+const { BotError } = require('./util');
function createKeyProvider(config, { fetchImpl = globalThis.fetch, log = console } = {}) {
function createKeyProvider(config, { fetchImpl = globalThis.fetch, log = console } = {}) {
let pem = config.network.publicKey ? crypto.createPublicKey(config.network.publicKey).export({ type: 'spki', format: 'pem' }) : null;
let pem = config.network.publicKey ? crypto.createPublicKey(config.network.publicKey).export({ type: 'spki', format: 'pem' }) : null;
@@ -59,11 +63,40 @@ function createUserAuth(config, keys) {
@@ -59,11 +63,40 @@ function createUserAuth(config, keys) {
let claims;
let claims;
try { claims = jwt.verify(token, key, { algorithms: ['RS256'], issuer: config.network.issuer }); } catch { return null; }
try { claims = jwt.verify(token, key, { algorithms: ['RS256'], issuer: config.network.issuer }); } catch { return null; }
if (!claims || typeof claims !== 'object') return null;
if (!claims || typeof claims !== 'object') return null;
- if (typeof claims.sub === 'string' && /^(svc|app|mod):/.test(claims.sub)) return null;
- if (claims.actor_type === 'service') return null;
+ if (typeof claims.sub === 'string' && /^(svc|app|mod|node):/.test(claims.sub)) return null;
+ if (claims.actor_type === 'service' || claims.actor_type === 'node') return null;
return claims;
return claims;
}
}
return { client, verify };
return { client, verify };
}
}
-module.exports = { createKeyProvider, createUserAuth };
+/**
+ * Network's view of a node principal (OpenVibe.Network server/registry/node-principals.js principalView):
+ * { 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.
+ * Any other failure throws 503 bot.network_unavailable.
+ */
+function createNodePrincipals(config, { fetchImpl = globalThis.fetch } = {}) {
+ const tokens = serviceAuth.createTokenClient({
+ 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' : ''}`;
+ let res;
+ try {
+ res = await fetchImpl(`${config.network.internalUrl}${path}`, { method, headers: { ...(await tokens.authHeaders()), Accept: 'application/json' }, 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;
+ }
+ return { get: (id) => call('GET', id), revoke: (id) => call('POST', id) };
+}
+
+module.exports = { createKeyProvider, createUserAuth, createNodePrincipals };
@@ -5,7 +5,8 @@
@@ -5,7 +5,8 @@
*
*
* wss://…/device one outbound connection per device. Auth by `Authorization: Bearer <credential>`
* wss://…/device one outbound connection per device. Auth by `Authorization: Bearer <credential>`
* on the upgrade, never a query string; an unauthenticated socket may only send
* on the upgrade, never a query string; an unauthenticated socket may only send
- * `pair`. Heartbeats every 1 s; offline after 2 missed + 3 s grace. Commands are
+ * `pair`. A Network-paired machine presents its node token instead (bound on first
+ * use) and renews it with `reauth` before the 330 s deadline, else close 4002. Heartbeats every 1 s; offline after 2 missed + 3 s grace. Commands are
* never queued for an offline device and never replayed after a reconnect.
* never queued for an offline device and never replayed after a reconnect.
* wss://…/control a signed-in person (Network session cookie or Bearer) or a service token holding
* wss://…/control a signed-in person (Network session cookie or Bearer) or a service token holding
* `bot.robot.control` acting for `X-OV-Subject`. Joins a robot, sends commands; the
* `bot.robot.control` acting for `X-OV-Subject`. Joins a robot, sends commands; the
@@ -17,12 +18,14 @@
@@ -17,12 +18,14 @@
const { WebSocketServer } = require('ws');
const { WebSocketServer } = require('ws');
const { capabilities, ids } = require('openvibe-contracts');
const { capabilities, ids } = require('openvibe-contracts');
const { json, iso, prefixedId } = require('./util');
const { json, iso, prefixedId } = require('./util');
-const { userPrincipal, verifyService, PRINCIPAL_SUB, decodePayload } = require('./api/auth');
+const { userPrincipal, verifyService, verifyNode, isNodeToken, PRINCIPAL_SUB, decodePayload } = require('./api/auth');
const { getProfile } = require('./profiles');
const { getProfile } = require('./profiles');
const OPEN = 1;
const OPEN = 1;
const MAX_BACKLOG = 64; // frames a device may send before its authentication completes
const MAX_BACKLOG = 64; // frames a device may send before its authentication completes
const ACCESS_COOKIE = 'ov_token';
const ACCESS_COOKIE = 'ov_token';
+const DEVICE_KINDS = new Set(['onboard', 'bridge', 'server']);
+const MAX_AGENT_VERSION = 40;
function parseCookies(header) {
function parseCookies(header) {
const out = {};
const out = {};
@@ -79,7 +82,8 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -79,7 +82,8 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
if (cred) {
if (cred) {
conn.backlog = [];
conn.backlog = [];
try {
try {
- await authenticateDevice(conn, cred);
+ if (isNodeToken(cred)) await authenticateNode(conn, cred);
+ else await authenticateDevice(conn, cred);
sendHello(conn);
sendHello(conn);
await sendConfig(conn);
await sendConfig(conn);
await bringOnline(conn);
await bringOnline(conn);
@@ -112,6 +116,48 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -112,6 +116,48 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
attachDevice(conn, device);
attachDevice(conn, device);
}
}
+ /** A node token: verified offline, then bound to its device (created on first use from Network's record). */
+ async function authenticateNode(conn, nodeToken) {
+ const r = verifyNode(nodeToken, { publicKey: keys.get(), issuer: config.network.issuer, audience: config.audience });
+ if (!r.ok) throw new Error(`node token refused: ${r.code}`);
+ const device = await domain.devices.bindNode(r.principal);
+ conn.node = { principal: r.principal, reauthBy: now() + config.device.nodeReauthMs };
+ attachDevice(conn, device);
+ }
+ /** `reauth`: a fresh node token for the same principal moves the deadline; anything else leaves it. */
+ function onReauth(conn, msg) {
+ const r = verifyNode(typeof msg.token === 'string' ? msg.token : '', { publicKey: keys.get(), issuer: config.network.issuer, audience: config.audience });
+ if (!r.ok) return sendError(conn, 'bot.reauth_refused', r.reason);
+ if (r.principal !== conn.node.principal) return sendError(conn, 'bot.reauth_refused', 'the token is for another machine');
+ conn.node.reauthBy = now() + config.device.nodeReauthMs;
+ return undefined;
+ }
+
+ /**
+ * `status` of a Network-paired device: device_kind, drivers, capabilities and agent_version are persisted
+ * when they differ from the row. Any invalid one → bot.bad_frame and the row unchanged.
+ */
+ async function persistDeclared(conn, msg) {
+ const d = conn.device;
+ const has = (k) => msg[k] !== undefined;
+ if ((has('device_kind') && !DEVICE_KINDS.has(msg.device_kind))
+ || (has('drivers') && !(Array.isArray(msg.drivers) && msg.drivers.every((x) => typeof x === 'string')))
+ || (has('capabilities') && !(msg.capabilities && typeof msg.capabilities === 'object' && !Array.isArray(msg.capabilities)))
+ || (has('agent_version') && !(typeof msg.agent_version === 'string' && msg.agent_version.length <= MAX_AGENT_VERSION))) {
+ return sendError(conn, 'bot.bad_frame', `device_kind must be one of ${[...DEVICE_KINDS].join('|')}, drivers an array of strings, capabilities an object, agent_version a string of at most ${MAX_AGENT_VERSION}`);
+ }
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
+ const change = {};
+ if (has('device_kind') && msg.device_kind !== d.kind) change.kind = msg.device_kind;
+ if (has('drivers') && !same(msg.drivers, json(d.drivers, []))) change.drivers = msg.drivers;
+ if (has('capabilities') && !same(msg.capabilities, json(d.capabilities, {}))) change.capabilities = msg.capabilities;
+ if (has('agent_version') && msg.agent_version !== (d.agent_version ?? null)) change.agent_version = msg.agent_version;
+ if (!Object.keys(change).length) return undefined;
+ const updated = await domain.devices.updateDeclared(d.id, change);
+ if (updated) conn.device = updated;
+ return undefined;
+ }
+
function attachDevice(conn, device) {
function attachDevice(conn, device) {
const existing = deviceConns.get(device.id);
const existing = deviceConns.get(device.id);
if (existing && existing.ws !== conn.ws) { try { existing.ws.close(4000, 'replaced by a newer connection'); } catch { /* gone */ } dropDevice(existing, { silent: true }); }
if (existing && existing.ws !== conn.ws) { try { existing.ws.close(4000, 'replaced by a newer connection'); } catch { /* gone */ } dropDevice(existing, { silent: true }); }
@@ -183,7 +229,11 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -183,7 +229,11 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
return sendFrame(conn, 'heartbeat_ack', ack);
return sendFrame(conn, 'heartbeat_ack', ack);
}
}
case 'telemetry': return onTelemetry(conn, msg);
case 'telemetry': return onTelemetry(conn, msg);
- case 'status': conn.status = msg; for (const r of deviceRobotIds(conn.device)) broadcast(r); return undefined;
+ case 'status':
+ conn.status = msg;
+ for (const r of deviceRobotIds(conn.device)) broadcast(r);
+ return conn.device.node_principal ? persistDeclared(conn, msg) : undefined;
+ case 'reauth': if (conn.node) return onReauth(conn, msg); return sendError(conn, 'bot.unknown_message', `unknown type ${msg.type}`);
case 'ack': case 'nack': return onAck(conn, msg);
case 'ack': case 'nack': return onAck(conn, msg);
case 'estop_state': return onEstopState(conn, msg);
case 'estop_state': return onEstopState(conn, msg);
default: return sendError(conn, 'bot.unknown_message', `unknown type ${msg.type}`);
default: return sendError(conn, 'bot.unknown_message', `unknown type ${msg.type}`);
@@ -446,6 +496,12 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -446,6 +496,12 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
function checkHeartbeats() {
function checkHeartbeats() {
for (const conn of [...deviceConns.values()]) {
for (const conn of [...deviceConns.values()]) {
+ if (conn.node && now() > conn.node.reauthBy) {
+ // The node token lapsed with no valid reauth: the machine must authenticate again.
+ try { conn.ws.close(4002, 'reauth required'); } catch { /* gone */ }
+ dropDevice(conn);
+ continue;
+ }
if (conn.online && now() - conn.lastHeartbeat > offlineAfterMs) {
if (conn.online && now() - conn.lastHeartbeat > offlineAfterMs) {
conn.online = false;
conn.online = false;
domain.devices.setOnline(conn.device, false).catch((e) => log.warn(`[Bot] offline event: ${e.message}`));
domain.devices.setOnline(conn.device, false).catch((e) => log.warn(`[Bot] offline event: ${e.message}`));
@@ -2,7 +2,9 @@
@@ -2,7 +2,9 @@
/**
/**
* Stand-ins for the services Bot talks to, each on a random port.
* Stand-ins for the services Bot talks to, each on a random port.
*
*
- * startNetwork() JWKS and user/service JWTs (signUser, signService, newUser)
+ * startNetwork() JWKS and user/service JWTs (signUser, signService, newUser); node principals as
+ * GET /internal/node-principals/:id and POST …/:id/revoke answer them (addNode, signNode,
+ * nodes, revokes), scoped to the calling service like Network's own routes
* startEvents() POST /api/v1/events recording what Bot's outbox relays; GET /api/health
* startEvents() POST /api/v1/events recording what Bot's outbox relays; GET /api/health
*/
*/
const http = require('http');
const http = require('http');
@@ -33,6 +35,34 @@ async function startNetwork() {
@@ -33,6 +35,34 @@ async function startNetwork() {
return jwt.sign({ sub: String(u.networkId || ++n), subject_id: u.subject, username: u.username, display_name: u.display_name || u.username, role: u.role || 'user' },
return jwt.sign({ sub: String(u.networkId || ++n), subject_id: u.subject, username: u.username, display_name: u.display_name || u.username, role: u.role || 'user' },
privatePem, { algorithm: 'RS256', issuer, expiresIn: '1h' });
privatePem, { algorithm: 'RS256', issuer, expiresIn: '1h' });
}
}
+ // Node principals: the view Network's principalView answers (server/registry/node-principals.js).
+ // `unscoped: true` answers a principal to any service, so Bot's own paired_for check is exercised too.
+ const nodes = new Map();
+ const revokes = [];
+ function addNode({ owner, ref, service = 'bot', status = 'active', name = 'garage-pi', unscoped = false } = {}) {
+ const id = ids.newId('node');
+ nodes.set(id, {
+ unscoped,
+ view: {
+ principal: id, node_id: `n-${id.slice(4).toLowerCase()}`, name, owner: { kind: 'user', subject: owner },
+ home_cell: 'cell-eu-1', status, paired_for: service ? { service, ref } : null,
+ last_seen_at: null, created_at: new Date().toISOString(), revoked_at: status === 'revoked' ? new Date().toISOString() : null,
+ },
+ });
+ return id;
+ }
+ function signNode(principal, { aud = ['openvibe.bot'], expSec = 300 } = {}) {
+ const now = Math.floor(Date.now() / 1000);
+ return serviceAuth.signServiceToken({ iss: issuer, sub: `node:${principal}`, actor_type: 'node', aud, cap: [], iat: now, exp: now + expSec, jti: `tok_${crypto.randomBytes(12).toString('hex')}` }, privatePem);
+ }
+ /** The calling service (svc:<id> → <id>) of a valid network.node.manage token for openvibe.network, or null. */
+ function nodeManager(req) {
+ const h = String(req.headers.authorization || '');
+ const r = serviceAuth.verifyServiceToken(h.slice(7), { publicKey: publicPem, issuer, audience: 'openvibe.network' });
+ if (!h.startsWith('Bearer ') || !r.ok || !(r.claims.cap || []).includes('network.node.manage')) return null;
+ return r.claims.sub.replace(/^svc:/, '');
+ }
+
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) };
}
}
@@ -46,11 +76,23 @@ async function startNetwork() {
@@ -46,11 +76,23 @@ async function startNetwork() {
const cap = String(body.scope || '').split(/\s+/).filter(Boolean);
const cap = String(body.scope || '').split(/\s+/).filter(Boolean);
return send(res, 200, { access_token: signService({ sub: `svc:${body.client_id}`, aud: [body.audience || 'openvibe.bot'], cap }), token_type: 'Bearer', expires_in: 300 });
return send(res, 200, { access_token: signService({ sub: `svc:${body.client_id}`, aud: [body.audience || 'openvibe.bot'], cap }), token_type: 'Bearer', expires_in: 300 });
}
}
+ const np = /^\/internal\/node-principals\/([^/]+)(\/revoke)?$/.exec(req.url);
+ if (np && (req.method === (np[2] ? 'POST' : 'GET'))) {
+ const service = nodeManager(req);
+ if (!service) return send(res, 401, { code: 'token.invalid' });
+ const n = nodes.get(decodeURIComponent(np[1]));
+ if (!n || (!n.unscoped && (!n.view.paired_for || n.view.paired_for.service !== service))) return send(res, 404, { code: 'registry.unknown_node' });
+ if (np[2]) {
+ revokes.push(n.view.principal);
+ if (n.view.status !== 'revoked') Object.assign(n.view, { status: 'revoked', revoked_at: new Date().toISOString() });
+ }
+ return send(res, 200, n.view);
+ }
send(res, 404, { error: 'not found' });
send(res, 404, { error: 'not found' });
});
});
const url = await listen(server);
const url = await listen(server);
issuer = url;
issuer = url;
- return { url, publicPem, signService, signUser, newUser, close: () => new Promise((r) => server.close(r)) };
+ return { url, publicPem, signService, signUser, newUser, addNode, signNode, nodes, revokes, close: () => new Promise((r) => server.close(r)) };
}
}
async function startEvents() {
async function startEvents() {
new file mode 100644
new file mode 100644
@@ -0,0 +1,199 @@
@@ -0,0 +1,199 @@
+'use strict';
+// Network-paired machines (T2 §9.2 B1, dual-accept): a node token binds a device from Network's record,
+// POST /devices/bind issues its publish key without a credential, the /device socket speaks first exactly
+// as for a credential, `status` persists what the machine declares, `reauth` keeps the socket, and revoke
+// and rotate reach Network and the machine. Credential devices keep today's behaviour.
+const assert = require('assert');
+const crypto = require('crypto');
+const { boot, check, done } = require('./helpers/app');
+
+const WHIP_BASE = 'https://whip.test/ingest';
+const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');
+
+(async () => {
+ const t = await boot({ env: { BOT_WHIP_BASE: WHIP_BASE } });
+ const alex = t.network.newUser('alex');
+ const sam = t.network.newUser('sam');
+
+ const bind = (token) => t.call('POST', '/api/v1/devices/bind', { token });
+ const deviceRow = (id) => t.db.maybe('SELECT * FROM devices WHERE id = $1', [id]);
+ const connect = (token) => t.ws('/device', { headers: { Authorization: `Bearer ${token}` } });
+ /** A robot of `owner` and a machine Network paired for it. */
+ async function paired(owner = alex, node = {}) {
+ const { robot } = await t.robot(owner);
+ return { robot, principal: t.network.addNode({ owner: owner.subject, ref: robot.id, ...node }) };
+ }
+
+ await check('bind creates the device from Network\'s record with safe initial values, and answers no credential', async () => {
+ const { robot, principal } = await paired();
+ const r = await bind(t.network.signNode(principal));
+ assert.strictEqual(r.status, 201, r.text);
+ assert.strictEqual(r.headers.get('cache-control'), 'no-store');
+ assert.deepStrictEqual(Object.keys(r.json).sort(), ['device_id', 'profile', 'publish_key', 'robot_id', 'whip_url']);
+ assert.match(r.json.device_id, /^dev_/);
+ assert.strictEqual(r.json.robot_id, robot.id);
+ assert.strictEqual(r.json.profile.id, 'sim.rover');
+ assert.ok(r.json.publish_key.length >= 40);
+ assert.strictEqual(r.json.whip_url, `${WHIP_BASE}/${r.json.publish_key}`);
+ const d = await deviceRow(r.json.device_id);
+ assert.deepStrictEqual(d.robot_ids, [robot.id]);
+ assert.strictEqual(d.name, 'garage-pi');
+ assert.strictEqual(d.kind, 'onboard');
+ assert.deepStrictEqual(d.drivers, []);
+ assert.deepStrictEqual(d.capabilities, {});
+ assert.strictEqual(d.agent_version, null);
+ assert.strictEqual(d.credential_hash, null);
+ assert.strictEqual(d.node_principal, principal);
+ assert.strictEqual(d.publish_key_hash, sha256(r.json.publish_key));
+ });
+
+ await check('bind twice answers the same device and a new publish key; the old key stops working', async () => {
+ const { principal } = await paired();
+ const first = await bind(t.network.signNode(principal));
+ const second = await bind(t.network.signNode(principal));
+ assert.strictEqual(second.status, 201, second.text);
+ assert.strictEqual(second.json.device_id, first.json.device_id);
+ assert.notStrictEqual(second.json.publish_key, first.json.publish_key);
+ const d = await deviceRow(first.json.device_id);
+ assert.strictEqual(d.publish_key_hash, sha256(second.json.publish_key));
+ assert.notStrictEqual(d.publish_key_hash, sha256(first.json.publish_key));
+ assert.strictEqual(Number((await t.db.maybe('SELECT count(*) AS n FROM devices WHERE node_principal = $1', [principal])).n), 1);
+ });
+
+ await check('bind refuses anything but a node token for openvibe.bot', async () => {
+ const { principal } = await paired();
+ const network = await bind(t.network.signNode(principal, { aud: ['openvibe.network'] }));
+ assert.strictEqual(network.status, 401, network.text);
+ const user = await t.call('POST', '/api/v1/devices/bind', { user: alex });
+ assert.strictEqual(user.status, 401, user.text);
+ assert.strictEqual(user.json.code, 'bot.node_token_required');
+ const service = await t.call('POST', '/api/v1/devices/bind', { cap: ['bot.*'] });
+ assert.strictEqual(service.status, 401, service.text);
+ });
+
+ await check('an upgrade with a node token and no prior bind gets hello then config first, and no paired frame', async () => {
+ const { robot, principal } = await paired();
+ const c = await connect(t.network.signNode(principal));
+ const config = await c.waitFor((m) => m.type === 'config');
+ assert.ok(config);
+ assert.deepStrictEqual(c.messages.slice(0, 2).map((m) => m.type), ['hello', 'config']);
+ assert.ok(!c.messages.some((m) => m.type === 'paired'));
+ const hello = c.messages[0];
+ assert.deepStrictEqual(hello.robot_ids, [robot.id]);
+ const d = await deviceRow(hello.device_id);
+ assert.strictEqual(d.node_principal, principal);
+ assert.strictEqual(d.publish_key_hash, null, 'no publish key until the machine calls bind');
+ assert.ok(t.hub.isOnline(d.id));
+ // A later bind finds the same device.
+ const b = await bind(t.network.signNode(principal));
+ assert.strictEqual(b.json.device_id, d.id);
+ c.close();
+ });
+
+ await check('a status with device_kind, drivers, capabilities and agent_version updates the row; invalid values do not', async () => {
+ const { principal } = await paired();
+ const c = await connect(t.network.signNode(principal));
+ const hello = await c.waitFor((m) => m.type === 'hello');
+ c.send({ v: 1, seq: 1, ts: Date.now(), type: 'status', firmware: 'x', device_kind: 'bridge', drivers: ['pca9685', 'picamera2'], capabilities: { camera: true }, agent_version: '0.3.1' });
+ let d;
+ for (let i = 0; i < 40; i++) { d = await deviceRow(hello.device_id); if (d.kind === 'bridge') break; await t.wait(25); }
+ assert.strictEqual(d.kind, 'bridge');
+ assert.deepStrictEqual(d.drivers, ['pca9685', 'picamera2']);
+ assert.deepStrictEqual(d.capabilities, { camera: true });
+ assert.strictEqual(d.agent_version, '0.3.1');
+ for (const bad of [{ device_kind: 'toaster' }, { drivers: 'pca9685' }, { drivers: [1] }, { agent_version: 'x'.repeat(41) }, { capabilities: [] }]) {
+ const before = c.messages.filter((m) => m.type === 'error').length;
+ c.send({ v: 1, seq: 2, ts: Date.now(), type: 'status', ...bad });
+ await c.waitFor(() => c.messages.filter((m) => m.type === 'error').length > before);
+ const err = c.messages.filter((m) => m.type === 'error').pop();
+ assert.strictEqual(err.code, 'bot.bad_frame', JSON.stringify(bad));
+ }
+ const after = await deviceRow(hello.device_id);
+ assert.strictEqual(after.kind, 'bridge');
+ assert.deepStrictEqual(after.drivers, ['pca9685', 'picamera2']);
+ assert.strictEqual(after.agent_version, '0.3.1');
+ c.close();
+ });
+
+ await check('a credential device\'s status stays memory-only', async () => {
+ const { robot, pairing } = await t.robot(alex);
+ const p = await t.call('POST', '/api/v1/pair', { token: null, body: { robot: robot.id, code: pairing.code, drivers: ['a'] } });
+ const c = await connect(p.json.credential);
+ await c.waitFor((m) => m.type === 'config');
+ c.send({ v: 1, seq: 1, ts: Date.now(), type: 'status', device_kind: 'server', drivers: ['b'], agent_version: '9' });
+ c.send({ v: 1, seq: 2, ts: Date.now(), type: 'reauth', token: 'x' });
+ const err = await c.waitFor((m) => m.type === 'error');
+ assert.strictEqual(err.code, 'bot.unknown_message', 'reauth is not a credential device\'s frame');
+ const d = await deviceRow(p.json.device_id);
+ assert.strictEqual(d.kind, 'onboard');
+ assert.deepStrictEqual(d.drivers, ['a']);
+ c.close();
+ });
+
+ await check('a principal paired for another owner\'s robot, revoked, or not for Bot → 403 and close 4002', async () => {
+ const { robot: samsRobot } = await t.robot(sam);
+ const cases = {
+ 'another owner\'s robot': t.network.addNode({ owner: alex.subject, ref: samsRobot.id }),
+ revoked: (await paired(alex, { status: 'revoked' })).principal,
+ 'paired for another service (Network answers 404)': (await paired(alex, { service: 'live' })).principal,
+ 'paired for another service (seen)': (await paired(alex, { service: 'live', unscoped: true })).principal,
+ 'unknown on Network': 'nod_01ARZ3NDEKTSV4RRFFQ69G5FAV',
+ };
+ for (const [name, principal] of Object.entries(cases)) {
+ const r = await bind(t.network.signNode(principal));
+ assert.strictEqual(r.status, 403, `${name}: ${r.text}`);
+ assert.strictEqual(r.json.code, 'bot.node_not_bound', name);
+ const c = await connect(t.network.signNode(principal));
+ assert.strictEqual(await c.waitForClose(), 4002, name);
+ assert.ok(!c.messages.some((m) => m.type === 'hello'), name);
+ assert.strictEqual(await t.db.maybe('SELECT id FROM devices WHERE node_principal = $1', [principal]), null, name);
+ }
+ });
+
+ await check('no valid reauth within 330 s of the last token → close 4002; a valid one keeps the socket', async () => {
+ const { principal } = await paired();
+ const other = (await paired()).principal;
+ const c = await connect(t.network.signNode(principal));
+ await c.waitFor((m) => m.type === 'config');
+ t.clock.offset += 200 * 1000;
+ c.send({ v: 1, seq: 1, ts: Date.now(), type: 'reauth', token: t.network.signNode(other) });
+ assert.strictEqual((await c.waitFor((m) => m.type === 'error')).code, 'bot.reauth_refused', 'another machine\'s token is no reauth');
+ c.send({ v: 1, seq: 2, ts: Date.now(), type: 'reauth', token: t.network.signNode(principal) });
+ await t.wait(100);
+ t.clock.offset += 200 * 1000; // 400 s after connect, 200 s after the reauth
+ await t.wait(400);
+ assert.ok(!c.ended, 'a valid reauth moved the deadline');
+ t.clock.offset += 131 * 1000; // 331 s after the reauth
+ assert.strictEqual(await c.waitForClose(), 4002);
+ t.clock.offset = 0;
+ });
+
+ await check('revoke of a Network-paired device revokes the principal on Network and closes the socket; it never binds again', async () => {
+ const { principal } = await paired();
+ const c = await connect(t.network.signNode(principal));
+ const hello = await c.waitFor((m) => m.type === 'hello');
+ const r = await t.call('POST', `/api/v1/devices/${hello.device_id}/revoke`, { user: alex });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.ok(t.network.revokes.includes(principal));
+ assert.strictEqual(await c.waitForClose(), 4003);
+ const again = await bind(t.network.signNode(principal));
+ assert.strictEqual(again.status, 403);
+ assert.strictEqual(again.json.code, 'bot.node_not_bound');
+ });
+
+ await check('rotate of a Network-paired device asks the machine to rotate and answers no credential', async () => {
+ const { principal } = await paired();
+ const c = await connect(t.network.signNode(principal));
+ const hello = await c.waitFor((m) => m.type === 'hello');
+ const r = await t.call('POST', `/api/v1/devices/${hello.device_id}/rotate`, { user: alex });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.json.sent, true);
+ assert.ok(!('credential' in r.json) && !('publish_key' in r.json), r.text);
+ assert.ok(await c.waitFor((m) => m.type === 'rotate'));
+ assert.strictEqual((await deviceRow(hello.device_id)).credential_hash, null);
+ c.close();
+ });
+
+ await t.close();
+ done();
+})();