openvibers/openvibe.network Commits

the machines hub: a person's machines on openvibe.network (#457)

OP OpenVibers committed 13 days ago · 50f4cd0e39 · 10 files, +746 −10

* the machines hub: a person's machines on openvibe.network

The machines of a person are the network's: Bot stays the registry underneath,
and Network fronts it for the person and for the apps the person consented to
(plan L34, step M2).

The hub's own routes, under the person's session or an app holding the
consented scope:
  GET    /api/v1/machines             the caller's machines
  GET    /api/v1/machines/roles       the role catalogue
  GET    /api/v1/machines/:id         one machine
  POST   /api/v1/machines/pairings    a one-time pairing code
  PATCH  /api/v1/machines/:id         a new name
  PUT    /api/v1/machines/:id/roles   the machine's roles
  POST   /api/v1/machines/:id/revoke  the machine is no longer the caller's

Every call goes to Bot as the person (Bot keeps the authority and the
devices), signed as a service of the network with bot.machine.manage and the
person named in X-OV-Subject. An app must hold the capability and have the
person's consent; a role outside the catalogue is refused before Bot is called.

The hub republishes Bot's online and offline changes as network.machine.online
and network.machine.offline, the roles as network.machine.roles_changed, a
revocation as network.machine.revoked, and a redemption as
network.machine.paired — one event each.

* the hub's outbound call to Bot is reviewed, and a machine id is checked

The SSRF ratchet lists every outbound call site: the hub's call to Bot is added
with what it may reach (Bot's internal URL from the operator's config, the paths
fixed, the machine id a checked dev_ id, the person in a header).

The hub now checks the machine id itself before it calls Bot: anything that is
not a Bot machine id is answered as a machine that is not there, and never
reaches Bot.

* the machines hub's contracts are 0.258.0

main released 0.257.0 (Your AI), so the machines hub is the next release.

* openvibe-contracts v0.261.0 (was v0.256.0)

The machines hub contracts (network.machine@1, its roles and its events) are released in v0.261.0. Bumps the pin, the lockfile and the version lines.

* pairing a machine and choosing its roles come from the person's own session

A token that acts for the person (an app's, or a run's) may read the machines,
rename one and revoke one: none of those can give the person a machine or a
capability. Pairing a machine and choosing what it does are different: a machine
paired that way, or a role given that way, would carry the person's jobs and
their desks, so only the person in their own session decides that.

A delegated token on either route is answered 403 capability.owner_denied, and
Bot is never called.

* Merge remote-tracking branch 'origin/feat/machines-hub-network' into feat/machines-hub-network

* Merge remote-tracking branch 'origin/main' into pin/net457

* the hub's contracts are 0.261.0, and the catalogue is the manifests'

The pin moved to Contracts 0.261.0, where the products' manifests carry the roles
they install, so the catalogue is their union (ai.host, bot.robot, codes.worker,
desks.host) and the local copy is only the fallback for an older pin.

* Merge remote-tracking branch 'origin/feat/machines-hub-network' into feat/machines-hub-network

Changes

ddiff --git a/README.md b/README.md
iindex aaf6ecdc..2b89e94a 100644
---- a/README.md
++++ b/README.md
@@@ -40,6 +40,12 @@ Identity and account service for the OpenVibe network. Manages user accounts, OA
- node principals: a person's paired machine (`nod_…`) with a one-time pairing code, a scoped credential and a
token; a revoke — by the pairing service, its owner, or an account deletion — stops the machine and is announced
as `network.node.revoked@1`
++- the machines hub (`/api/v1/machines`, plan L34): a person's machines, their roles and what they do right now.
++ Bot stays the registry underneath — Network calls it as the person, so Bot checks the machine is that person's
++ own — and Network publishes what changes (`network.machine.online`, `.offline`, `.paired`, `.roles_changed`,
++ `.revoked`). A token that acts for the person (an app's, or a run's) may read the machines, rename one and
++ revoke one; pairing a machine and choosing its roles come from the person's own session only, because a
++ machine paired that way would carry the person's jobs and their desks.
- the service registry (`/api/v1/registry/*`, `/.well-known/openvibe`), the status page, the network
changelog proxy, and the files at `/shared/*` (the pinned openvibe-shared release)
@@@ -67,9 +73,14 @@ Implemented here (the service manifest's `capabilities`, audience `openvibe.netw
`network.project.manage|read`, `network.secret.use`, `network.staff.read` and `network.status.incident`. The grants other
services hold are seeded in [server/identity/principals.js](server/identity/principals.js).
++The hub's own two, `network.machines.read` and `network.machines.manage` (Contracts 0.261.0), are checked by name
++here, next to the token and the consent: the check is the same one the contracts' own guard makes, and this one also
++holds the app's consent and the person's own session (see [server/registry/machines.js](server/registry/machines.js)).
++
Called elsewhere: Network is the issuer, so it signs its own `svc:network` tokens: `events.event.publish`
--(and `events.delivery.admin` for the admin Events views) at OpenVibe.Events, and `ai.run.create` /
--`ai.run.read` at OpenVibe.AI for the footer's site copy.
++(and `events.delivery.admin` for the admin Events views) at OpenVibe.Events, `ai.run.create` /
++`ai.run.read` at OpenVibe.AI for the footer's site copy, and `bot.machine.manage` at OpenVibe.Bot for the
++machines hub.
## Architecture
@@@ -809,7 +820,7 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md). The rules the code keeps:
MIT (`license` in [package.json](package.json)).
<!-- versions:start -->
--- openvibe-contracts: v0.256.0
++- openvibe-contracts: v0.261.0
- openvibe-sdk: v0.74.0
- openvibe-shared: v4.66.0
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex 3a4b3000..644ae516 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -6,7 +6,7 @@
"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"port": 4000,
"domain": "openvibe.network (with auth., api., admin., my. and themes.openvibe.network in its manifest)",
-- "contracts": "openvibe-contracts v0.256.0",
++ "contracts": "openvibe-contracts v0.261.0",
"sdk": "openvibe-sdk v0.74.0",
"shared": "openvibe-shared v4.66.0",
"capabilities": [
@@@ -22,6 +22,8 @@
"network.follows.read",
"network.follows.write",
"network.integration.github.read",
++ "network.machines.manage",
++ "network.machines.read",
"network.modules.read",
"network.modules.write",
"network.notifications.push",
@@@ -47,7 +49,8 @@
"serves /shared/* from its pinned openvibe-shared",
"per-actor limits (openvibe-sdk/limits)",
"universal telemetry: per-route aggregated platform.telemetry-sample@1 plus per-flush HTTP autoscaling gauges (openvibe-sdk/telemetry)",
-- "the Dock's state per person: GET and PUT /api/v1/me/dock (network.dock-state@1, network.dock-result@1)"
++ "the Dock's state per person: GET and PUT /api/v1/me/dock (network.dock-state@1, network.dock-result@1)",
++ "the machines hub (/api/v1/machines, plan L34): a person's machines, their roles and what they do right now, with Bot as the registry underneath (Network calls it as the person) and network.machine.online/offline/paired/roles_changed/revoked published through OpenVibe.Events"
],
"updated": "2026-10-31"
}
ddiff --git a/package-lock.json b/package-lock.json
iindex 52cc1137..e20b1b6c 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -20,7 +20,7 @@
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.256.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.261.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.66.0",
"pg": "^8.23.0",
@@@ -1595,8 +1595,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.256.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.256.0",
++ "version": "0.261.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.261.0",
++ "integrity": "sha512-SiXOcXRL9+WivMkKM1W6sGNJMD4uCBR9o7KYZvs99hT2xgYxDvP4rM7tLBudGu1boPH+coMdUXVQAAWbS/3JDw==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex 81ed218d..558ea51d 100644
---- a/package.json
++++ b/package.json
@@@ -24,7 +24,7 @@
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.256.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.261.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.66.0",
"pg": "^8.23.0",
ddiff --git a/server/config.js b/server/config.js
iindex 38d471e1..76eeb8a3 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -129,5 +129,10 @@ module.exports = {
billing: {
internalUrl: process.env.OV_BILLING_INTERNAL_URL || 'http://127.0.0.1:4600',
},
++ // OpenVibe.Bot: the registry of the machines. The hub (server/registry/machines.js) calls it as the
++ // person, so Bot stays the authority on what a person may do with their own machines.
++ bot: {
++ internalUrl: process.env.OV_BOT_INTERNAL_URL || 'http://127.0.0.1:4630',
++ },
},
};
ddiff --git a/server/index.js b/server/index.js
iindex 407f8683..90ece385 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -503,6 +503,17 @@ const notifyAccountData = async (userId, n) => await notificationService.create(
// The machine itself (slice N4c): its own capabilities and credential, resolved from its node token alone.
app.use('/api/v1/node/self', require('./identity/principals').guard('network.node.self.manage'), require('./registry/node-principals').selfRouter());
}
++// The machines hub (plan L34, step M2): a person's machines on openvibe.network/machines. Bot stays the registry
++// underneath; Network fronts it and calls it as the person, so Bot checks the machine is that person's own.
++// The internal route is Bot's report of a machine's state (network.node.manage, the capability it already holds).
++{
++ const machines = require('./registry/machines');
++ const bot = machines.createBotClient({ baseUrl: config.services.bot.internalUrl, privateKey, issuer: config.jwt.issuer });
++ const machineRouters = machines.routers({ bot, requireAuth, publicKey, issuer: config.jwt.issuer });
++ app.use('/api/v1/machines', rateLimit({ windowMs: 60_000, max: 60 }), machineRouters.hub);
++ const machineInternal = machines.internalRouters({ guard: require('./identity/principals').guard('network.node.manage') });
++ app.use('/internal', machineInternal.internal);
++}
app.get('/internal/blocks', require('./identity/principals').guard('network.blocks.read'), require('./identity/blocks').internalHandler(db));
// The follow graph (WS-E task 4, ADR-030): public counts, a person's own follows, and who follows a target
// (its owner, or network.follows.read, service token only). Every change is network.follow.* (server/identity/follows.js).
ddiff --git a/server/registry/machines.js b/server/registry/machines.js
nnew file mode 100644
iindex 00000000..2dfcee10
---- /dev/null
++++ b/server/registry/machines.js
@@@ -0,0 +1,414 @@
++'use strict';
++/**
++ * The machines hub (plan L34, step M2): a person's machines on openvibe.network/machines.
++ *
++ * Bot stays the registry of the machines. Network fronts it: every route here calls Bot's machines API as the
++ * person (with a service token of Network's own, naming the person in X-OV-Subject), so Bot checks that the
++ * machine is that person's own and answers as the authority it is. Nothing of the machine is stored here.
++ *
++ * GET /api/v1/machines network.machines.read → { machines: [machine] }
++ * GET /api/v1/machines/roles network.machines.read → { roles: [role] } (the catalogue)
++ * GET /api/v1/machines/:id network.machines.read → { machine }
++ * POST /api/v1/machines/pairings network.machines.manage → 201 { pairing }
++ * PATCH /api/v1/machines/:id network.machines.manage → { machine } (a new name)
++ * PUT /api/v1/machines/:id/roles network.machines.manage → { machine } (the whole set)
++ * POST /api/v1/machines/:id/revoke network.machines.manage → { machine }
++ * POST /internal/machines/status network.node.manage → 202 (Bot's report)
++ *
++ * Who may call: the person, with their own session; or an app holding the capability with the person's
++ * consent — the token names the person in on_behalf_of, and the person's grant for the capability must still be
++ * approved. A machine that is not that person's is not there at all (404, never 403), so nobody can probe whose
++ * machine an id is.
++ *
++ * The events (through OpenVibe.Events, server/developer/event-relay.js): network.machine.online and
++ * .offline (Bot's report of a machine's state, once per change), .paired (the machine redeemed its code),
++ * .roles_changed and .revoked (the person's own changes).
++ */
++const express = require('express');
++const crypto = require('crypto');
++const { capabilities, http, ids, serviceAuth, validate } = require('openvibe-contracts');
++const eventRelay = require('../developer/event-relay');
++
++const READ = 'network.machines.read';
++const MANAGE = 'network.machines.manage';
++/** The capability Network holds at Bot: it acts for a person, and Bot checks the machine is that person's. */
++const BOT_CAP = 'bot.machine.manage';
++const SELF_AUDIENCE = 'openvibe.network';
++const SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
++const MACHINE = /^dev_[0-9A-HJKMNP-TV-Z]{26}$/;
++const PRINCIPAL = /^nod_[0-9A-HJKMNP-TV-Z]{26}$/;
++const KINDS = new Set(['onboard', 'bridge', 'server']);
++const TOKEN_TTL_S = 300;
++
++/**
++ * The catalogue of machine roles, for a pin that does not carry the products' manifests. This checkout pins
++ * Contracts 0.261.0, where the catalogue lives: each product declares the roles it installs
++ * (registry.service-manifest@1's machineRoles) and the hub shows the union. The copy below is the fallback for
++ * an older pin; keep it in step with the manifests.
++ */
++const LOCAL_ROLES = [
++ { id: 'codes.worker', name: 'Codes worker', installs: 'the Codes job runner (the coding agent and the job sandbox)', shows: 'Runs your coding jobs, such as a task you start on openvibe.codes.' },
++ { id: 'desks.host', name: 'Desk host', installs: 'the desk sandbox (a private browser desk for every session)', shows: "Hosts desks for agents, yours and other people's." },
++ { id: 'ai.host', name: 'AI host', installs: "the local model server (Ollama, LM Studio) as OpenVibe Node's ai plugin", shows: 'Serves your own AI models, on your own machine.' },
++ { id: 'bot.robot', name: 'Robot', installs: 'the robot drivers the profile asks for', shows: 'Drives a robot, and takes its commands.' },
++];
++
++/** The roles the products declare (Contracts 0.261.0), or the local copy when the pin does not carry them. */
++function catalogue() {
++ let declared = [];
++ try {
++ const contracts = require('openvibe-contracts');
++ declared = (contracts.services.manifests || []).flatMap((m) => (Array.isArray(m.machineRoles) ? m.machineRoles : []));
++ } catch { /* the catalogue is the local copy */ }
++ return declared.length ? declared : LOCAL_ROLES;
++}
++
++/** The role ids, in the catalogue's order. */
++const roleIds = () => catalogue().map((r) => r.id);
++
++/** Whether the catalogue has that role. */
++const isKnownRole = (id) => roleIds().includes(String(id || ''));
++
++/**
++ * The roles of a request, checked: a list of ids, each in the catalogue. A role outside it is refused here as
++ * well as by Bot, so the person is told before anything is called. → the ids, or null when one is unknown.
++ */
++function normaliseRoles(list) {
++ if (!Array.isArray(list)) return null;
++ const want = roleIds();
++ const out = [];
++ for (const raw of list) {
++ const id = String(raw || '');
++ if (!want.includes(id)) return null;
++ if (!out.includes(id)) out.push(id);
++ }
++ return want.filter((id) => out.includes(id));
++}
++
++/** One machine as the hub shows it (network.machine@1): what Bot said, and nothing else. */
++function machineOf(m) {
++ const out = {
++ id: String(m.id || ''),
++ kind: KINDS.has(m.kind) ? m.kind : 'onboard',
++ online: m.online === true,
++ roles: Array.isArray(m.roles) ? m.roles.filter((r) => typeof r === 'string') : [],
++ running: Array.isArray(m.running) ? m.running : [],
++ };
++ if (typeof m.name === 'string' && m.name) out.name = m.name;
++ if (typeof m.os === 'string' && m.os) out.os = m.os;
++ if (typeof m.last_seen_at === 'string' && m.last_seen_at) out.last_seen_at = m.last_seen_at;
++ if (typeof m.node_version === 'string' && m.node_version) out.node_version = m.node_version;
++ if (typeof m.node_id === 'string' && PRINCIPAL.test(m.node_id)) out.principal_id = m.node_id;
++ return out;
++}
++
++/**
++ * Network's own service token for Bot: sub svc:network, audience openvibe.bot, cap bot.machine.manage. Network
++ * is the token issuer, so it signs its own five-minute token instead of asking itself for one (the same way the
++ * events relay publishes under svc:network).
++ */
++function createBotClient({ baseUrl, privateKey, issuer, fetchImpl = globalThis.fetch, log = console, now = () => Date.now() }) {
++ let cached = null;
++ function token() {
++ const iat = Math.floor(now() / 1000);
++ if (cached && cached.exp - 60 > iat) return cached.token;
++ const claims = {
++ iss: issuer, sub: 'svc:network', actor_type: 'service', aud: ['openvibe.bot'], cap: [BOT_CAP],
++ iat, exp: iat + TOKEN_TTL_S, jti: `tok_${crypto.randomBytes(12).toString('hex')}`,
++ };
++ cached = { token: serviceAuth.signServiceToken(claims, privateKey), exp: claims.exp };
++ return cached.token;
++ }
++ /**
++ * One call to Bot, as the person. Bot's own refusals are passed through (404 for a machine that is not the
++ * person's, 422 for a role it does not know, 403 for a missing capability); anything else is an outage.
++ */
++ async function call(method, path, body, subject) {
++ let res;
++ try {
++ res = await fetchImpl(`${baseUrl}${path}`, {
++ method,
++ headers: {
++ Authorization: `Bearer ${token()}`, Accept: 'application/json',
++ 'X-OV-Subject': subject, ...(body ? { 'Content-Type': 'application/json' } : {}),
++ },
++ ...(body ? { body: JSON.stringify(body) } : {}),
++ signal: AbortSignal.timeout(8000),
++ });
++ } catch (e) {
++ throw Object.assign(new Error(`Bot did not answer for the machine: ${e.message}`), { status: 503, code: 'registry.bot_unavailable' });
++ }
++ const out = await res.json().catch(() => null);
++ if (!res.ok || !out) {
++ const code = (out && out.code) || 'registry.bot_unavailable';
++ const status = res.status >= 400 && res.status < 600 ? res.status : 503;
++ throw Object.assign(new Error((out && (out.detail || out.error)) || `Bot answered ${res.status}`), { status, code });
++ }
++ return out;
++ }
++ return {
++ list: (subject) => call('GET', '/api/v1/machines', null, subject),
++ get: (subject, id) => call('GET', `/api/v1/machines/${encodeURIComponent(id)}`, null, subject),
++ rename: (subject, id, name) => call('PATCH', `/api/v1/machines/${encodeURIComponent(id)}`, { name }, subject),
++ roles: (subject, id, roles) => call('PUT', `/api/v1/machines/${encodeURIComponent(id)}/roles`, { roles }, subject),
++ revoke: (subject, id) => call('POST', `/api/v1/machines/${encodeURIComponent(id)}/revoke`, {}, subject),
++ pairing: (subject) => call('POST', '/api/v1/machines/pairings', {}, subject),
++ };
++}
++
++/** The problem a route answers with: the status and the code, in the estate's shape. */
++class MachineError extends Error {
++ constructor(status, code, message) { super(message); this.status = status; this.code = code; }
++}
++
++/**
++ * The event envelope for a machine event (events.event-envelope@1). Only the ids, the owner and when: never a
++ * credential, a hash or an address.
++ */
++function envelope(type, { actor, subject, payload, at }) {
++ return {
++ event_id: ids.newId('event', Date.parse(at)), event_type: type, version: 1, source: 'network',
++ actor, timestamp: at, visibility: 'internal', subject, payload,
++ };
++}
++
++/**
++ * Publish a machine event into the outbox (OpenVibe.Events). The payload contracts are Contracts 0.261.0: the
++ * envelope is checked against its contract here, the payload by the tests.
++ */
++async function publish(db, type, { actor, subject, payload, at }) {
++ const env = envelope(type, { actor, subject, payload, at });
++ const v = validate('events.event-envelope@1', env);
++ if (!v.valid) throw new Error(`machines: bad event ${JSON.stringify((v.errors || []).slice(0, 3))}`);
++ await eventRelay.writerFor(db).enqueue(db, env);
++ return env;
++}
++
++/**
++ * The guard for a hub route: the person's own session, or a token that names a person and holds the capability.
++ *
++ * The capabilities network.machines.read and network.machines.manage are in Contracts 0.261.0, which this checkout
++ * pins, so the capability is checked by name (capabilities.grants is a plain string check, the same one the
++ * contracts' own guard makes) and the token is verified here: this guard also holds the app's consent and the
++ * person's own session, which the contracts' guard does not.
++ */
++function hubGuard(capability, { db, publicKey, issuer, requireAuth }) {
++ return async function hubGuardMiddleware(req, res, next) {
++ const auth = String(req.headers.authorization || '');
++ if (auth.startsWith('Bearer ')) {
++ // A service or app token (Network's own issuer signed it). Anything else — the person's own session
++ // token, for one — is not one, and the session guard below decides.
++ const r = serviceAuth.verifyServiceToken(auth.slice(7).trim(), { publicKey, issuer, audience: SELF_AUDIENCE });
++ if (r.ok) {
++ const claims = r.claims;
++ if (!capabilities.grants(claims.cap || [], capability)) {
++ return http.sendProblem(res, 403, 'capability.denied', { detail: `${capability} not granted` });
++ }
++ const person = String(claims.on_behalf_of || '');
++ if (!SUBJECT.test(person)) {
++ return http.sendProblem(res, 403, 'capability.owner_denied', { detail: 'the token names no person to act for' });
++ }
++ // The person's consent: an app's grant for this capability must still be approved. A service
++ // token (a run token) holds its own grant, checked when it was minted.
++ const appId = String(claims.sub || '').replace(/^app:/, '');
++ if (claims.actor_type === 'app') {
++ const database = typeof db === 'function' ? db(req) : db;
++ const grant = await database.prepare('SELECT status FROM dev_grants WHERE app_id = ? AND capability = ?').get(appId, capability);
++ if (!grant || grant.status !== 'approved') {
++ return http.sendProblem(res, 403, 'capability.owner_denied', { detail: 'the person did not consent to this' });
++ }
++ }
++ req.hub = {
++ kind: claims.actor_type === 'app' ? 'app' : 'service',
++ sub: claims.sub,
++ subject: person,
++ actor: claims.actor_type === 'app'
++ ? { type: 'app', id: appId }
++ : { type: 'service', id: String(claims.sub || '').replace(/^svc:/, '') },
++ };
++ return next();
++ }
++ }
++ return requireAuth(req, res, next);
++ };
++}
++
++/**
++ * The routes of the hub (plan L34, step M2).
++ *
++ * bot the client for Bot's machines API (createBotClient)
++ * requireAuth the person's session guard
++ * publicKey Network's public key (the app tokens are signed by Network)
++ * issuer Network's issuer (the same string its tokens carry)
++ */
++function routers({ bot, requireAuth, publicKey, issuer, db = (req) => req.app.locals.db, now = () => Date.now() }) {
++ const hub = express.Router();
++ hub.use(http.middleware());
++ const read = hubGuard(READ, { db, publicKey, issuer, requireAuth });
++ const manage = hubGuard(MANAGE, { db, publicKey, issuer, requireAuth });
++
++ /** The person the call acts for: the app's token names them, or the session is theirs. */
++ async function subjectOf(req) {
++ if (req.hub) return req.hub.subject;
++ const { ensureUserSubject } = require('../identity/subjects');
++ const sid = await ensureUserSubject(req.app.locals.db, req.user);
++ if (!SUBJECT.test(String(sid || ''))) throw new MachineError(403, 'registry.guest', 'sign in with an account to see your machines');
++ return sid;
++ }
++ /** Who changed it: the person, or the app that acts with their consent. */
++ const actorOf = (req, subject) => (req.hub && req.hub.actor ? req.hub.actor : { type: 'user', id: subject });
++
++ /**
++ * The machine the route names. A machine's id is Bot's own (dev_ and 26 characters), so anything else never
++ * reaches Bot: the hub answers as it answers for a machine that is not there.
++ */
++ const machineId = (req) => {
++ const id = String((req.params && req.params.id) || '');
++ if (!MACHINE.test(id)) throw new MachineError(404, 'registry.machine_not_found', 'there is no such machine');
++ return id;
++ };
++
++ /**
++ * The person's own session, and nothing else, may add a machine or a role.
++ *
++ * A token that acts for the person (an app's, or a run's) may read the machines, rename one and revoke one:
++ * none of those can give the person a new machine or a new capability. Pairing a machine and choosing what it
++ * does are different: a machine paired that way, or a role given that way, would carry the person's jobs and
++ * their desks, so only the person in their own session decides that.
++ */
++ const ownSession = (req, res, next) => {
++ if (req.hub) {
++ return http.sendProblem(res, 403, 'capability.owner_denied', {
++ detail: 'pair a machine and choose its roles from your own session on openvibe.network',
++ });
++ }
++ return next();
++ };
++
++ const send = async (res, fn) => {
++ try { return await fn(); } catch (e) {
++ if (e instanceof MachineError) return http.sendProblem(res, e.status, e.code, { detail: e.message });
++ if (e && e.status && e.code) return http.sendProblem(res, e.status, e.code, { detail: e.message });
++ console.error('[machines]', e.message);
++ return http.sendProblem(res, 500, 'registry.failed', { detail: 'the machines could not be read or changed' });
++ }
++ };
++
++ // The catalogue, so the page can show what a machine may do for the network.
++ hub.get('/roles', read, async (req, res) => await send(res, async () => {
++ res.set('Cache-Control', 'private, no-store');
++ res.json({ roles: catalogue() });
++ }));
++
++ // The person's machines: Bot's list, as the hub shows them.
++ hub.get('/', read, async (req, res) => await send(res, async () => {
++ res.set('Cache-Control', 'private, no-store');
++ const subject = await subjectOf(req);
++ const out = await bot.list(subject);
++ const machines = (out && Array.isArray(out.machines) ? out.machines : []).map(machineOf);
++ res.json({ machines });
++ }));
++
++ hub.get('/:id', read, async (req, res) => await send(res, async () => {
++ res.set('Cache-Control', 'private, no-store');
++ const subject = await subjectOf(req);
++ const out = await bot.get(subject, machineId(req));
++ res.json({ machine: machineOf(out && out.machine) });
++ }));
++
++ // A one-time code to pair a machine with the person's account, and the command to run on it. The code is
++ // shown once here and never again.
++ hub.post('/pairings', manage, ownSession, express.json({ limit: '8kb' }), async (req, res) => await send(res, async () => {
++ res.set('Cache-Control', 'private, no-store');
++ const subject = await subjectOf(req);
++ const out = await bot.pairing(subject);
++ const p = (out && out.pairing) || {};
++ res.status(201).json({ pairing: {
++ code: String(p.code || ''), expires_at: p.expires_at, installer: String(p.installer || ''),
++ ...(p.pairing_id ? { pairing_id: p.pairing_id } : {}),
++ } });
++ }));
++
++ // A new name for the machine.
++ hub.patch('/:id', manage, express.json({ limit: '8kb' }), async (req, res) => await send(res, async () => {
++ res.set('Cache-Control', 'private, no-store');
++ const subject = await subjectOf(req);
++ const name = typeof (req.body || {}).name === 'string' ? (req.body.name).trim().slice(0, 80) : '';
++ if (!name) throw new MachineError(422, 'registry.invalid_machine_name', 'a name is 1 to 80 characters');
++ const out = await bot.rename(subject, machineId(req), name);
++ res.json({ machine: machineOf(out && out.machine) });
++ }));
++
++ // The machine's roles for the network: the whole set, not a change. A role outside the catalogue is refused.
++ hub.put('/:id/roles', manage, ownSession, express.json({ limit: '8kb' }), async (req, res) => await send(res, async () => {
++ res.set('Cache-Control', 'private, no-store');
++ const subject = await subjectOf(req);
++ const roles = normaliseRoles((req.body || {}).roles);
++ if (!roles) throw new MachineError(422, 'registry.unknown_role', 'that is not a machine role');
++ const id = machineId(req);
++ const out = await bot.roles(subject, id, roles);
++ const machine = machineOf(out && out.machine);
++ await publish(req.app.locals.db, 'network.machine.roles_changed', {
++ actor: actorOf(req, subject),
++ subject: { type: 'machine', id: machine.id },
++ payload: { machine_id: machine.id, owner: subject, roles: machine.roles, at: new Date(now()).toISOString() },
++ at: new Date(now()).toISOString(),
++ });
++ res.json({ machine });
++ }));
++
++ // The machine is no longer the person's: its credential dies at Bot, and its principal on Network with it.
++ hub.post('/:id/revoke', manage, express.json({ limit: '8kb' }), async (req, res) => await send(res, async () => {
++ res.set('Cache-Control', 'private, no-store');
++ const subject = await subjectOf(req);
++ const id = machineId(req);
++ const out = await bot.revoke(subject, id);
++ const machine = machineOf(out && out.machine);
++ const at = new Date(now()).toISOString();
++ const reason = typeof (req.body || {}).reason === 'string' ? (req.body.reason).trim().slice(0, 500) : '';
++ await publish(req.app.locals.db, 'network.machine.revoked', {
++ actor: actorOf(req, subject),
++ subject: { type: 'machine', id: machine.id },
++ payload: {
++ machine_id: machine.id, owner: subject, at,
++ ...(machine.principal_id ? { principal_id: machine.principal_id } : {}),
++ ...(reason ? { reason } : {}),
++ },
++ at,
++ });
++ res.json({ machine });
++ }));
++
++ return { hub };
++}
++
++/**
++ * Bot's report of a machine's state (POST /internal/machines/status, network.node.manage — the capability Bot
++ * already holds for the pairing authority). Each change is published once as network.machine.online or
++ * network.machine.offline; Bot reports a change, not a heartbeat, so the event says what changed.
++ */
++function internalRouters({ guard, now = () => Date.now() }) {
++ const internal = express.Router();
++ internal.post('/machines/status', guard, express.json({ limit: '8kb' }), async (req, res) => {
++ res.set('Cache-Control', 'no-store');
++ const body = req.body || {};
++ const machineId = String(body.machine_id || '');
++ const owner = String(body.owner || '');
++ if (!MACHINE.test(machineId) || !SUBJECT.test(owner)) {
++ return http.sendProblem(res, 400, 'registry.invalid_machine_status', { detail: 'machine_id and owner are required' });
++ }
++ const at = new Date(now()).toISOString();
++ const online = body.online === true;
++ const payload = { machine_id: machineId, owner, at };
++ if (PRINCIPAL.test(String(body.principal_id || ''))) payload.principal_id = String(body.principal_id);
++ await publish(req.app.locals.db, online ? 'network.machine.online' : 'network.machine.offline', {
++ actor: { type: 'service', id: 'bot' },
++ subject: { type: 'machine', id: machineId },
++ payload, at,
++ });
++ res.status(202).json({ ok: true });
++ });
++ return { internal };
++}
++
++module.exports = { catalogue, roleIds, isKnownRole, normaliseRoles, machineOf, createBotClient, hubGuard, routers, internalRouters, publish, READ, MANAGE, BOT_CAP, LOCAL_ROLES };
ddiff --git a/server/registry/node-principals.js b/server/registry/node-principals.js
iindex 18670cf7..580c3536 100644
---- a/server/registry/node-principals.js
++++ b/server/registry/node-principals.js
@@@ -171,7 +171,11 @@ async function redeem(db, body, now = Date.now()) {
VALUES (?, ?, ?, 'user', ?, ?, 'community', 'active', ?, ?, ?, ?, ?, ?)`)
.run(principalId, `n-${ulid.toLowerCase()}`, homeCell, row.owner_subject, name, sha256(credential), row.service, row.ref, at, `pairing:${row.id}`, at);
await t.prepare('UPDATE platform_node_pairings SET principal_id = ? WHERE id = ?').run(principalId, row.id);
-- return { principal: await t.prepare('SELECT * FROM platform_node_principals WHERE id = ?').get(principalId), credential };
++ const principal = await t.prepare('SELECT * FROM platform_node_principals WHERE id = ?').get(principalId);
++ // The machine paired with the person's account (plan L34): the hub's network.machine.paired event, in the
++ // same transaction as the principal it names.
++ await eventRelay.writerFor(db).enqueue(db, pairedEnvelope(principal, { at }));
++ return { principal, credential };
});
if (result.error) throw new RegistryError(...result.error);
return result;
@@@ -221,6 +225,23 @@ function reasonOf(raw) {
return r;
}
++/**
++ * The network.machine.paired@1 envelope for a machine that just redeemed its code (plan L34): the machine paired
++ * with the person's account. Only the ids, the owner and when: never the credential or its hash. The payload
++ * contract is Contracts 0.261.0, so the envelope is checked here and the payload by the tests.
++ */
++function pairedEnvelope(p, { at }) {
++ const payload = { principal_id: p.id, node_id: p.node_id, owner: p.owner_subject, at };
++ const env = {
++ event_id: ids.newId('event', Date.parse(at)), event_type: 'network.machine.paired', version: 1, source: 'network',
++ actor: { type: 'service', id: 'network' }, timestamp: at, visibility: 'internal',
++ subject: { type: 'machine', id: p.id }, payload,
++ };
++ const ev = validate('events.event-envelope@1', env);
++ if (!ev.valid) throw new Error(`node-principals: bad event ${JSON.stringify((ev.errors || []).slice(0, 3))}`);
++ return env;
++}
++
/**
* Revoke one active principal row and enqueue its network.node.revoked@1 on the ambient transaction, so the change
* and the event share it. → changes (0 or 1): a row already revoked changes nothing and emits nothing.
ddiff --git a/test/machines-hub.test.js b/test/machines-hub.test.js
nnew file mode 100644
iindex 00000000..0ab716f9
---- /dev/null
++++ b/test/machines-hub.test.js
@@@ -0,0 +1,269 @@
++'use strict';
++// The machines hub (plan L34, step M2): Network fronts Bot for a person, and for the apps that person consented
++// to. What this proves:
++// - a person sees their own machines, and the hub calls Bot as them (X-OV-Subject);
++// - an app without the capability, or without the person's consent, is refused;
++// - rename, roles and revoke reach Bot as the person, and a role outside the catalogue never reaches it;
++// - a pairing code works once and expires;
++// - each change is published once: network.machine.roles_changed, .revoked, .paired, .online and .offline.
++// node --import ./test/helpers/pg-preload.mjs test/machines-hub.test.js
++const assert = require('assert');
++const crypto = require('crypto');
++const http = require('http');
++const express = require('express');
++const { ids, serviceAuth } = require('openvibe-contracts');
++const { getDb } = require('../server/db/database');
++const principals = require('../server/identity/principals');
++const machines = require('../server/registry/machines');
++const nodePrincipals = require('../server/registry/node-principals');
++
++(async () => {
++const db = getDb();
++await principals.ensureSchema(db);
++
++const keys = crypto.generateKeyPairSync('rsa', {
++ modulusLength: 2048, privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, publicKeyEncoding: { type: 'spki', format: 'pem' },
++});
++const config = { jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' } };
++
++const mk = async (name) => {
++ const subject = `usr_${ids.ulid()}`;
++ const id = (await db.prepare('INSERT INTO users (username, email, password_hash, subject_id) VALUES (?, ?, ?, ?) RETURNING id')
++ .run(name, `${name}@example.test`, 'x', subject)).lastInsertRowid;
++ return await db.prepare('SELECT * FROM users WHERE id = ?').get(id);
++};
++const ana = await mk('hubana');
++const bob = await mk('hubbob');
++
++// ── The fake Bot: the registry underneath. It answers per person, and it records every call. ──
++const calls = [];
++const owned = new Map(); // subject → [machine]
++const machineOf = (id) => ({ id, name: 'garage-pi', kind: 'onboard', online: true, roles: [], running: [], node_id: null });
++const botServer = http.createServer(async (req, res) => {
++ const chunks = [];
++ for await (const c of req) chunks.push(c);
++ const body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}') : null;
++ const subject = req.headers['x-ov-subject'] || null;
++ calls.push({ method: req.method, url: req.url, subject, body, auth: String(req.headers.authorization || '') });
++ const answer = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
++ const list = () => (owned.get(subject) || []).map(machineOf);
++ if (req.method === 'GET' && req.url === '/api/v1/machines') return answer(200, { machines: list() });
++ if (req.method === 'GET' && req.url.startsWith('/api/v1/machines/')) {
++ const id = decodeURIComponent(req.url.slice('/api/v1/machines/'.length));
++ const mine = list().find((m) => m.id === id);
++ return mine ? answer(200, { machine: machineOf(id) }) : answer(404, { code: 'bot.device_not_found' });
++ }
++ if (req.method === 'POST' && req.url === '/api/v1/machines/pairings') {
++ return answer(201, { pairing: { code: 'ABCD-EFGH', expires_at: new Date(Date.now() + 600000).toISOString(), pairing_id: `pair_${ids.ulid()}`, installer: 'curl -fsSL https://openvibe.bot/install | sh -s -- --pairing pair_1 --code ABCD-EFGH --worker' } });
++ }
++ if (req.method === 'PATCH' && req.url.startsWith('/api/v1/machines/')) {
++ const id = decodeURIComponent(req.url.slice('/api/v1/machines/'.length));
++ const m = machineOf(id); m.name = body.name; return answer(200, { machine: m });
++ }
++ if (req.method === 'PUT' && req.url.endsWith('/roles')) {
++ const id = decodeURIComponent(req.url.slice('/api/v1/machines/'.length, -'/roles'.length));
++ const m = machineOf(id); m.roles = body.roles; return answer(200, { machine: m });
++ }
++ if (req.method === 'POST' && req.url.endsWith('/revoke')) {
++ const id = decodeURIComponent(req.url.slice('/api/v1/machines/'.length, -'/revoke'.length));
++ const m = machineOf(id); m.online = false; return answer(200, { machine: m });
++ }
++ answer(404, { code: 'bot.not_found' });
++});
++await new Promise((r) => botServer.listen(0, '127.0.0.1', r));
++const botUrl = `http://127.0.0.1:${botServer.address().port}`;
++
++const anaMachine = `dev_${ids.ulid()}`;
++const bobMachine = `dev_${ids.ulid()}`;
++owned.set(ana.subject_id, [anaMachine]);
++owned.set(bob.subject_id, [bobMachine]);
++
++const bot = machines.createBotClient({ baseUrl: botUrl, privateKey: keys.privateKey, issuer: config.jwt.issuer });
++
++// ── The server under test: Network's own app, with the hub's routes on it. ──
++const authRoutes = require('../server/auth/routes');
++const requireAuth = require('../server/auth/session').makeRequireAuth(() => ({ db, publicKey: keys.publicKey, config }), authRoutes.signToken);
++const tokenOf = (u) => authRoutes.signToken(u, keys.privateKey, config);
++const app = express();
++app.use(express.json());
++Object.assign(app.locals, { db, config, privateKey: keys.privateKey, publicKey: keys.publicKey });
++app.use('/api/v1/machines', machines.routers({ bot, requireAuth, publicKey: keys.publicKey, issuer: config.jwt.issuer }).hub);
++// Bot's report of a machine's state (network.node.manage, the capability Bot already holds).
++app.use('/internal', machines.internalRouters({ guard: principals.guard('network.node.manage') }).internal);
++const server = http.createServer(app);
++await new Promise((r) => server.listen(0, '127.0.0.1', r));
++const base = `http://127.0.0.1:${server.address().port}`;
++
++const call = async (method, p, { user = null, token = null, body, headers = {} } = {}) => {
++ const h = { 'content-type': 'application/json', ...(user ? { authorization: `Bearer ${tokenOf(user)}` } : {}), ...(token ? { authorization: `Bearer ${token}` } : {}), ...headers };
++ const r = await fetch(base + p, { method, headers: h, body: body === undefined ? undefined : JSON.stringify(body) });
++ return { status: r.status, body: await r.json().catch(() => ({})) };
++};
++const outbox = async (type) => (await db.prepare('SELECT envelope FROM network_event_outbox ORDER BY id').all())
++ .map((r) => (typeof r.envelope === 'string' ? JSON.parse(r.envelope) : r.envelope))
++ .filter((e) => e.event_type === type);
++
++// An app: a developer app with a grant the person approved (the consent), and a token that names them.
++const projectId = `prj_${ids.ulid()}`;
++const appId = `app_${ids.ulid()}`;
++await db.prepare('INSERT INTO dev_projects (id, owner_subject, name, environment_policy, created_at, created_by) VALUES (?, ?, ?, ?, ?, ?)')
++ .run(projectId, ana.subject_id, 'Hub app', 'sandbox+production', new Date().toISOString(), ana.subject_id);
++await db.prepare('INSERT INTO dev_apps (id, project_id, name, environment, oauth_client_id, client_type, created_at, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
++ .run(appId, projectId, 'Hub app', 'production', `client_${ids.ulid()}`, 'confidential', new Date().toISOString(), ana.subject_id);
++const grant = (capability, status) => db.prepare('INSERT INTO dev_grants (app_id, capability, audience, status, requested_by, requested_at) VALUES (?, ?, ?, ?, ?, ?)')
++ .run(appId, capability, 'openvibe.network', status, ana.subject_id, new Date().toISOString());
++const appToken = (capability, person = ana.subject_id) => serviceAuth.signServiceToken({
++ iss: config.jwt.issuer, sub: `app:${appId}`, actor_type: 'app', aud: ['openvibe.network'], cap: [capability],
++ project_id: projectId, env: 'production', on_behalf_of: person,
++ iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 300, jti: `tok_${ids.ulid()}`,
++}, keys.privateKey);
++// A run token: a service token that acts for a person (Codes C3), holding the capability and naming them.
++const runToken = (capability, person = ana.subject_id) => serviceAuth.signServiceToken({
++ iss: config.jwt.issuer, sub: 'svc:codes', actor_type: 'service', aud: ['openvibe.network'], cap: [capability],
++ on_behalf_of: person,
++ iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 300, jti: `tok_${ids.ulid()}`,
++}, keys.privateKey);
++
++try {
++ // ── A person sees their own machines, and the hub calls Bot as them. ──
++ const mine = await call('GET', '/api/v1/machines', { user: ana });
++ assert.strictEqual(mine.status, 200, JSON.stringify(mine.body));
++ assert.deepStrictEqual(mine.body.machines.map((m) => m.id), [anaMachine]);
++ assert.strictEqual(mine.body.machines[0].kind, 'onboard');
++ assert.deepStrictEqual(mine.body.machines[0].roles, []);
++ assert.deepStrictEqual(mine.body.machines[0].running, []);
++ const last = calls[calls.length - 1];
++ assert.strictEqual(last.subject, ana.subject_id, 'Bot is called as the person');
++ assert.ok(String(last.auth).startsWith('Bearer '), 'the hub carries a service token');
++ const theirs = await call('GET', '/api/v1/machines', { user: bob });
++ assert.deepStrictEqual(theirs.body.machines.map((m) => m.id), [bobMachine], 'nobody else\'s machine is in it');
++
++ // ── An app without the capability, or without the consent, is refused. ──
++ const noCap = await call('GET', '/api/v1/machines', { token: appToken('network.something.else') });
++ assert.strictEqual(noCap.status, 403, JSON.stringify(noCap.body));
++ assert.strictEqual(noCap.body.code, 'capability.denied');
++ await grant('network.machines.read', 'requested');
++ const notApproved = await call('GET', '/api/v1/machines', { token: appToken('network.machines.read') });
++ assert.strictEqual(notApproved.status, 403, JSON.stringify(notApproved.body));
++ assert.strictEqual(notApproved.body.code, 'capability.owner_denied');
++ await db.prepare("UPDATE dev_grants SET status = 'approved' WHERE app_id = ? AND capability = ?").run(appId, 'network.machines.read');
++ const approved = await call('GET', '/api/v1/machines', { token: appToken('network.machines.read') });
++ assert.strictEqual(approved.status, 200, JSON.stringify(approved.body));
++ assert.deepStrictEqual(approved.body.machines.map((m) => m.id), [anaMachine], 'the app sees the person\'s machines');
++ assert.strictEqual(calls[calls.length - 1].subject, ana.subject_id, 'the app acts for the person');
++
++ // ── Rename, roles and revoke reach Bot as the person. ──
++ await grant('network.machines.manage', 'approved');
++ const renamed = await call('PATCH', `/api/v1/machines/${anaMachine}`, { user: ana, body: { name: 'Studio' } });
++ assert.strictEqual(renamed.status, 200, JSON.stringify(renamed.body));
++ assert.strictEqual(renamed.body.machine.name, 'Studio');
++ const roles = await call('PUT', `/api/v1/machines/${anaMachine}/roles`, { user: ana, body: { roles: ['codes.worker', 'desks.host'] } });
++ assert.strictEqual(roles.status, 200, JSON.stringify(roles.body));
++ assert.deepStrictEqual(roles.body.machine.roles, ['codes.worker', 'desks.host']);
++ const revoke = await call('POST', `/api/v1/machines/${anaMachine}/revoke`, { user: ana, body: {} });
++ assert.strictEqual(revoke.status, 200, JSON.stringify(revoke.body));
++ const botCalls = calls.filter((c) => c.method !== 'GET');
++ for (const c of botCalls) assert.strictEqual(c.subject, ana.subject_id, 'every change reaches Bot as the person');
++
++ // ── A role outside the catalogue is refused, and Bot never sees it. ──
++ const before = calls.length;
++ const bad = await call('PUT', `/api/v1/machines/${bobMachine}/roles`, { user: ana, body: { roles: ['nope.nope'] } });
++ assert.strictEqual(bad.status, 422, JSON.stringify(bad.body));
++ assert.strictEqual(bad.body.code, 'registry.unknown_role');
++ assert.strictEqual(calls.length, before, 'Bot was not called');
++
++ // ── The catalogue: the four roles the products declare. ──
++ const cat = await call('GET', '/api/v1/machines/roles', { user: ana });
++ assert.strictEqual(cat.status, 200);
++ // The four roles the products declare; the order is the manifests' own, so the set is what matters.
++ assert.deepStrictEqual(cat.body.roles.map((r) => r.id).sort(), ['ai.host', 'bot.robot', 'codes.worker', 'desks.host']);
++ for (const r of cat.body.roles) {
++ assert.ok(r.name && r.installs && r.shows, `the role ${r.id} says what it is`);
++ }
++
++ // ── The events: one each. ──
++ const changed = await outbox('network.machine.roles_changed');
++ assert.strictEqual(changed.length, 1, 'the roles change is published once');
++ assert.strictEqual(changed[0].payload.machine_id, anaMachine);
++ assert.strictEqual(changed[0].payload.owner, ana.subject_id);
++ assert.deepStrictEqual(changed[0].payload.roles, ['codes.worker', 'desks.host']);
++ const revoked = await outbox('network.machine.revoked');
++ assert.strictEqual(revoked.length, 1, 'the revoke is published once');
++ assert.strictEqual(revoked[0].payload.machine_id, anaMachine);
++ assert.strictEqual(revoked[0].payload.owner, ana.subject_id);
++
++ // ── Pairing a machine and choosing its roles are the person's own session, and nothing else. ──
++ // A token that acts for the person (an app's, or a run's) may read, rename and revoke; it may never add a
++ // machine or a role: a machine paired that way would carry the person's jobs and their desks.
++ const OWN_SESSION = 'pair a machine and choose its roles from your own session on openvibe.network';
++ const beforeDelegated = calls.length;
++ for (const [name, token] of [['an app', appToken('network.machines.manage')], ['a run', runToken('network.machines.manage')]]) {
++ const pairing = await call('POST', '/api/v1/machines/pairings', { token, body: {} });
++ assert.strictEqual(pairing.status, 403, `${name} may not pair a machine: ${JSON.stringify(pairing.body)}`);
++ assert.strictEqual(pairing.body.code, 'capability.owner_denied');
++ assert.strictEqual(pairing.body.detail, OWN_SESSION);
++ const roles = await call('PUT', `/api/v1/machines/${anaMachine}/roles`, { token, body: { roles: ['codes.worker'] } });
++ assert.strictEqual(roles.status, 403, `${name} may not choose the roles: ${JSON.stringify(roles.body)}`);
++ assert.strictEqual(roles.body.code, 'capability.owner_denied');
++ assert.strictEqual(roles.body.detail, OWN_SESSION);
++ }
++ assert.strictEqual(calls.length, beforeDelegated, 'Bot was not called for any of them');
++ // The person, in their own session, may do both.
++ const ownPairing = await call('POST', '/api/v1/machines/pairings', { user: ana, body: {} });
++ assert.strictEqual(ownPairing.status, 201, JSON.stringify(ownPairing.body));
++ assert.ok(ownPairing.body.pairing && ownPairing.body.pairing.code, 'the code is theirs');
++ const ownRoles = await call('PUT', `/api/v1/machines/${anaMachine}/roles`, { user: ana, body: { roles: ['codes.worker'] } });
++ assert.strictEqual(ownRoles.status, 200, JSON.stringify(ownRoles.body));
++ assert.deepStrictEqual(ownRoles.body.machine.roles, ['codes.worker']);
++
++ // ── Bot's report of a machine's state: one event per change. ──
++ const serviceToken = await (async () => {
++ await db.prepare("UPDATE oauth_clients SET client_secret = 'shh' WHERE client_id = 'bot'").run();
++ const out = await principals.issueToken(db, { clientId: 'bot', clientSecret: 'shh', audience: 'openvibe.network', privateKey: keys.privateKey, issuer: config.jwt.issuer });
++ assert.strictEqual(out.status, 200, JSON.stringify(out.body));
++ return out.body.access_token;
++ })();
++ const report = await call('POST', '/internal/machines/status', {
++ token: serviceToken,
++ body: { machine_id: anaMachine, principal_id: null, owner: ana.subject_id, online: true, at: new Date().toISOString() },
++ });
++ assert.strictEqual(report.status, 202, JSON.stringify(report.body));
++ const online = await outbox('network.machine.online');
++ assert.strictEqual(online.length, 1, 'the machine is announced once');
++ assert.strictEqual(online[0].payload.machine_id, anaMachine);
++ assert.strictEqual(online[0].actor.type, 'service');
++ const off = await call('POST', '/internal/machines/status', {
++ token: serviceToken,
++ body: { machine_id: anaMachine, owner: ana.subject_id, online: false, at: new Date().toISOString() },
++ });
++ assert.strictEqual(off.status, 202);
++ assert.strictEqual((await outbox('network.machine.offline')).length, 1, 'and once when it goes');
++ const noToken = await call('POST', '/internal/machines/status', { body: { machine_id: anaMachine, owner: ana.subject_id, online: true } });
++ assert.strictEqual(noToken.status, 403, 'only a service with the capability may report');
++
++ // ── A pairing code works once, and it expires. ──
++ const pairing = await nodePrincipals.createPairing(db, 'bot', { owner: { kind: 'user', subject: ana.subject_id }, ref: `worker:${ana.subject_id}` });
++ assert.ok(pairing.code && pairing.expires_at, 'a code and an expiry');
++ const redeemed = await nodePrincipals.redeem(db, { code: pairing.code, pairing: pairing.pairing_id, name: 'garage-pi' });
++ assert.ok(redeemed.principal && redeemed.credential, 'the machine gets its credential');
++ const paired = await outbox('network.machine.paired');
++ assert.strictEqual(paired.length, 1, 'the pairing is published once');
++ assert.strictEqual(paired[0].payload.principal_id, redeemed.principal.id);
++ assert.strictEqual(paired[0].payload.owner, ana.subject_id);
++ let used = null;
++ try { await nodePrincipals.redeem(db, { code: pairing.code, pairing: pairing.pairing_id }); } catch (e) { used = e; }
++ assert.ok(used && used.code === 'registry.pairing_code_used', `the code works once: ${used && used.code}`);
++ const expired = await nodePrincipals.createPairing(db, 'bot', { owner: { kind: 'user', subject: bob.subject_id }, ref: `worker:${bob.subject_id}` }, Date.now() - 20 * 60 * 1000);
++ let dead = null;
++ try { await nodePrincipals.redeem(db, { code: expired.code, pairing: expired.pairing_id }); } catch (e) { dead = e; }
++ assert.ok(dead && dead.code === 'registry.pairing_code_expired', `the code expires: ${dead && dead.code}`);
++
++ console.log('machines-hub: ok');
++} catch (e) {
++ console.error(e);
++ process.exitCode = 1;
++} finally {
++ server.close();
++ botServer.close();
++}
++})();
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 96962387..0c118e87 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -133,6 +133,7 @@ const REVIEWED = {
'server/registry/deploy-drift.js fetch': [1, 'fixed host api.github.com'],
'server/registry/ecosystem.js fetch': [2, 'service URLs from openvibe-contracts manifests and operator env overrides'],
'server/registry/library-tags.js fetch': [1, 'fixed host api.github.com'],
++ 'server/registry/machines.js fetch': [1, 'the machines hub calls Bot: the internal URL from operator config (OV_BOT_INTERNAL_URL, loopback by default), the paths are fixed, the machine id is a checked dev_ id (percent-encoded) and the person is named in a header, never in the URL'],
'server/updates/routes.js fetch': [1, 'OV_BLOG_INTERNAL_URL (operator config)'],
};