openvibers/openvibe.network Commits
the machines hub: a person's machines on openvibe.network (#457)
Changes
@@@ -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 atoken; a revoke — by the pairing service, its owner, or an account deletion — stops the machine and is announcedas `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 networkchangelog 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 otherservices 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 -->@@@ -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"}@@@ -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",@@@ -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",@@@ -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',++ },},};@@@ -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).nnew file mode 100644@@@ -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 };@@@ -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.nnew file mode 100644@@@ -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();++}++})();@@@ -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)'],};
@@ -40,6 +40,12 @@ Identity and account service for the OpenVibe network. Manages user accounts, OA
@@ -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
- 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
token; a revoke — by the pairing service, its owner, or an account deletion — stops the machine and is announced
as `network.node.revoked@1`
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
- 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)
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
@@ -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
`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).
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`
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
## Architecture
@@ -809,7 +820,7 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md). The rules the code keeps:
@@ -809,7 +820,7 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md). The rules the code keeps:
MIT (`license` in [package.json](package.json)).
MIT (`license` in [package.json](package.json)).
<!-- versions:start -->
<!-- versions:start -->
-- openvibe-contracts: v0.256.0
+- openvibe-contracts: v0.261.0
- openvibe-sdk: v0.74.0
- openvibe-sdk: v0.74.0
- openvibe-shared: v4.66.0
- openvibe-shared: v4.66.0
<!-- versions:end -->
<!-- versions:end -->
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"port": 4000,
"port": 4000,
"domain": "openvibe.network (with auth., api., admin., my. and themes.openvibe.network in its manifest)",
"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",
"sdk": "openvibe-sdk v0.74.0",
"shared": "openvibe-shared v4.66.0",
"shared": "openvibe-shared v4.66.0",
"capabilities": [
"capabilities": [
@@ -22,6 +22,8 @@
@@ -22,6 +22,8 @@
"network.follows.read",
"network.follows.read",
"network.follows.write",
"network.follows.write",
"network.integration.github.read",
"network.integration.github.read",
+ "network.machines.manage",
+ "network.machines.read",
"network.modules.read",
"network.modules.read",
"network.modules.write",
"network.modules.write",
"network.notifications.push",
"network.notifications.push",
@@ -47,7 +49,8 @@
@@ -47,7 +49,8 @@
"serves /shared/* from its pinned openvibe-shared",
"serves /shared/* from its pinned openvibe-shared",
"per-actor limits (openvibe-sdk/limits)",
"per-actor limits (openvibe-sdk/limits)",
"universal telemetry: per-route aggregated platform.telemetry-sample@1 plus per-flush HTTP autoscaling gauges (openvibe-sdk/telemetry)",
"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"
"updated": "2026-10-31"
}
}
@@ -20,7 +20,7 @@
@@ -20,7 +20,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.66.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -1595,8 +1595,9 @@
@@ -1595,8 +1595,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"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",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -24,7 +24,7 @@
@@ -24,7 +24,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.66.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -129,5 +129,10 @@ module.exports = {
@@ -129,5 +129,10 @@ module.exports = {
billing: {
billing: {
internalUrl: process.env.OV_BILLING_INTERNAL_URL || 'http://127.0.0.1:4600',
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',
+ },
},
},
};
};
@@ -503,6 +503,17 @@ const notifyAccountData = async (userId, n) => await notificationService.create(
@@ -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.
// 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());
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));
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
// 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).
// (its owner, or network.follows.read, service token only). Every change is network.follow.* (server/identity/follows.js).
new file mode 100644
new file mode 100644
@@ -0,0 +1,414 @@
@@ -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 };
@@ -171,7 +171,11 @@ async function redeem(db, body, now = Date.now()) {
@@ -171,7 +171,11 @@ async function redeem(db, body, now = Date.now()) {
VALUES (?, ?, ?, 'user', ?, ?, 'community', 'active', ?, ?, ?, ?, ?, ?)`)
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);
.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);
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);
if (result.error) throw new RegistryError(...result.error);
return result;
return result;
@@ -221,6 +225,23 @@ function reasonOf(raw) {
@@ -221,6 +225,23 @@ function reasonOf(raw) {
return r;
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
* 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.
* and the event share it. → changes (0 or 1): a row already revoked changes nothing and emits nothing.
new file mode 100644
new file mode 100644
@@ -0,0 +1,269 @@
@@ -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();
+}
+})();
@@ -133,6 +133,7 @@ const REVIEWED = {
@@ -133,6 +133,7 @@ const REVIEWED = {
'server/registry/deploy-drift.js fetch': [1, 'fixed host api.github.com'],
'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/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/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)'],
'server/updates/routes.js fetch': [1, 'OV_BLOG_INTERNAL_URL (operator config)'],
};
};