openvibers/openvibe.bot Commits

Accept Network node principals beside device credentials (#9)

OP OpenVibers committed 2 Oct 2026 · a947ee2755 · 13 files, +500 −21

local checks ✓ 4/4 · no CI checks · review SHIP by DeepSeek harness (QUALITY=high)

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

Changes

ddiff --git a/.env.example b/.env.example
iindex fd1bf55..4ee9c7c 100644
---- a/.env.example
++++ b/.env.example
@@@ -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
ddiff --git a/docs/protocol.md b/docs/protocol.md
iindex c68c20f..f5dc3c0 100644
---- a/docs/protocol.md
++++ b/docs/protocol.md
@@@ -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 the
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`
@@@ -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 ranges
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.
++`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
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,
@@@ -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) |
ddiff --git a/migrations/0002_node_principal.sql b/migrations/0002_node_principal.sql
nnew file mode 100644
iindex 0000000..2667985
---- /dev/null
++++ b/migrations/0002_node_principal.sql
@@@ -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;
ddiff --git a/server/api/actor-limits.js b/server/api/actor-limits.js
iindex b5bc777..ffa3525 100644
---- a/server/api/actor-limits.js
++++ b/server/api/actor-limits.js
@@@ -9,7 +9,7 @@ const { createActorLimiter, createValkeyLimitStore, defaultActor } = require('op
function 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);
}
ddiff --git a/server/api/auth.js b/server/api/auth.js
iindex 34115ad..200b5b4 100644
---- a/server/api/auth.js
++++ b/server/api/auth.js
@@@ -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 };
ddiff --git a/server/api/v1.js b/server/api/v1.js
iindex 024cf01..3683d1e 100644
---- a/server/api/v1.js
++++ b/server/api/v1.js
@@@ -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 Network
res.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;
}
ddiff --git a/server/app.js b/server/app.js
iindex a8329be..e02a9ec 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -9,7 +9,7 @@
* /auth/* Network SSO session for people (the control WS accepts the ov_token cookie)
* GET / a one-line text placeholder; the panel and the site are designed separately
*
-- * createApp({ config, db, valkey, registry, keys, hub, outbox, 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, '..') });
ddiff --git a/server/config.js b/server/config.js
iindex c97a81e..36bc71b 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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.
ddiff --git a/server/domain/index.js b/server/domain/index.js
iindex 397c137..b2b0a72 100644
---- a/server/domain/index.js
++++ b/server/domain/index.js
@@@ -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 },
ddiff --git a/server/network.js b/server/network.js
iindex 87dcc6a..eaf29d7 100644
---- a/server/network.js
++++ b/server/network.js
@@@ -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 };
ddiff --git a/server/realtime.js b/server/realtime.js
iindex c855c88..88ab219 100644
---- a/server/realtime.js
++++ b/server/realtime.js
@@@ -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 completes
const 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 = () => Dat
if (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 = () => Dat
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) {
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 = () => Dat
return 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 = () => Dat
function 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}`));
ddiff --git a/test/helpers/stubs.js b/test/helpers/stubs.js
iindex cca0e97..107d1cc 100644
---- a/test/helpers/stubs.js
++++ b/test/helpers/stubs.js
@@@ -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() {
ddiff --git a/test/node-principal.test.js b/test/node-principal.test.js
nnew file mode 100644
iindex 0000000..f158395
---- /dev/null
++++ b/test/node-principal.test.js
@@@ -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();
++})();