openvibers/openvibe.network Commits

Network: a person's own machine stays off the public offers; Run reads it to place their jobs; grants for the worker chain (Bot, Run, Codes) (#177)

OP OpenVibers committed 30 days ago · bfd077c9a8 · 6 files, +129 −15

Plan T14/T16: a person's own machine runs jobs for that person only. This is the Network part of the chain; Bot, Run and Codes follow.

**Why**
- The Run → Bot → Node job chain has never carried a job: Network holds zero resource offers, because nothing reports a Node's worker.
- Run's placer would admit any person's user-owned machine for a job naming `user-owned`.
- Contracts 0.161.0 (Contracts#118) gave user-owned offers an `owner` and the rule "only that person's work".

**Changes**
- **A person's machine is never public.** `GET /api/v1/offers` leaves user-owned offers out (and so their owners), and `GET /api/v1/offers/:id` answers 404 for one. `server/registry/offers.js` `isPublic`.
- **Placement reads them internally.** `GET /internal/resources` (and `/:id`) now take `network.resource.report` or `network.registry.read`. Run reads without being able to report.
  - The new `principals.guardAny(caps)` lets the token's unverified claims choose which capability's guard runs; that guard then verifies the token and the capability in full, and audits, as before.
- **Grants**:
  - `bot` → `network.resource.report`: Bot reports its machines' worker offers.
  - `run` → `network.registry.read`: Run reads them, owner included.
  - `codes` → `run.job.submit`, `run.job.read`, `run.job.stream` and `run.job.cancel` (audience openvibe.run): Codes runs a coding job for the signed-in person in their project. Run checks the person's membership before every job.
- **Pin**: openvibe-contracts v0.153.0 → v0.161.0. The offer contract with `owner` is needed to accept Bot's reports; the whole suite passes on it.

**Tests**
- New `user-owned-offers`:
  - Bot's user-owned offer with its owner is accepted;
  - the public list and the public single read leave it and its owner out;
  - Run reads it whole with `network.registry.read`, and the reporter still can;
  - a service with neither capability gets 403, and Run cannot report;
  - the new grants exist.
- Whole suite: 120/120 files pass (`node test/run.js`).

Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex d39c0948..1d4f9d79 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -20,7 +20,7 @@
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.153.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.161.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.3",
"pg": "^8.23.0",
@@@ -1320,9 +1320,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.153.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.153.0",
-- "integrity": "sha512-WQ7p7ATKtTky9nI9yE9Agkm3rNKuyWljmrMCdZaqxg+FjVaziTqrFz0a3YMBTXJmNf5JTGXn/ioOmWjO95/lCA==",
++ "version": "0.161.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.161.0",
++ "integrity": "sha512-2F1KBisvjjMbLI72G4TLsL1Z5aehb+69/nYQGFtF2tcmCeF5ecNvVJaJLRmHnB1cK/vJt+rxIZssE3xdyoFGrQ==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex c8ec2be1..0f62f62b 100644
---- a/package.json
++++ b/package.json
@@@ -24,7 +24,7 @@
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.153.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.161.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.3",
"pg": "^8.23.0",
ddiff --git a/server/identity/principals.js b/server/identity/principals.js
iindex ba7a29ef..00591416 100644
---- a/server/identity/principals.js
++++ b/server/identity/principals.js
@@@ -262,6 +262,13 @@ const DEFAULT_GRANTS = [
// (robots.agent_control); its gate, limits and e-stop still judge every step.
['actor', 'bot.robot.read', 'openvibe.bot', []],
['actor', 'bot.robot.control', 'openvibe.bot', []],
++ // Plan T14/T16 (contracts 0.161.0): a person's own machine runs jobs for them. Bot reports the worker offers of the
++ // machines connected to it (each user-owned offer names its owner); Run reads them, owner included, to place a
++ // person's job only on that person's own machines; Codes starts, follows and cancels a coding job for the signed-in
++ // person in their project (X-OV-Subject + X-OV-Project), which Run checks before every job.
++ ['bot', 'network.resource.report', SELF_AUDIENCE, []],
++ ['run', 'network.registry.read', SELF_AUDIENCE, []],
++ ...['run.job.submit', 'run.job.read', 'run.job.stream', 'run.job.cancel'].map((c) => ['codes', c, 'openvibe.run', []]),
// Plan T2 lane B step 3: Host places and runs per project, so it reads a project's tenancy, placement and quotas.
// Other services get this row only when they ship a caller of GET /internal/projects/:project_id.
['host', 'network.project.read', SELF_AUDIENCE, []],
@@@ -591,4 +598,18 @@ function guard(capability, { ownApp, namespace } = {}) {
};
}
--module.exports = { ensureSchema, issueToken, guard, grantsFor, recordDecision, DEFAULT_GRANTS, REVOKED_GRANTS, RESTORED_GRANTS, TOKEN_TTL_S, SELF_AUDIENCE };
++/**
++ * A guard that accepts any one of `caps`. The bearer's (unverified) claims only pick which capability's guard runs;
++ * that guard then verifies the token and the capability in full, exactly as guard() alone does, and audits it.
++ */
++function guardAny(caps, opts) {
++ const guards = caps.map((c) => [c, guard(c, opts)]);
++ return function anyGuard(req, res, next) {
++ const claims = require('../developer/policy').unverifiedClaims(String(req.headers.authorization || '').slice(7).trim());
++ const held = claims && Array.isArray(claims.cap) ? claims.cap : [];
++ const pick = guards.find(([c]) => capabilities.grants(held, c)) || guards[0];
++ return pick[1](req, res, next);
++ };
++}
++
++module.exports = { ensureSchema, issueToken, guard, guardAny, grantsFor, recordDecision, DEFAULT_GRANTS, REVOKED_GRANTS, RESTORED_GRANTS, TOKEN_TTL_S, SELF_AUDIENCE };
ddiff --git a/server/index.js b/server/index.js
iindex 31f01165..39046f49 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -493,7 +493,11 @@ const notifyAccountData = async (userId, n) => await notificationService.create(
// capacity out; the report and the full internal read take network.resource.report. The amended ADR-048 moved the
// public routes off /api/v1/resources (now Network's resource index, below) to /api/v1/offers.
{
-- const offerRouters = require('./registry/offers').routers({ guard: require('./identity/principals').guard('network.resource.report') });
++ const { guard: capGuard, guardAny } = require('./identity/principals');
++ const offerRouters = require('./registry/offers').routers({
++ guard: capGuard('network.resource.report'),
++ readGuard: guardAny(['network.resource.report', 'network.registry.read']),
++ });
app.use('/api/v1/offers', offerRouters.pub);
app.use('/internal/resources', offerRouters.internal);
}
ddiff --git a/server/registry/offers.js b/server/registry/offers.js
iindex d0c2f1fb..37ec88cf 100644
---- a/server/registry/offers.js
++++ b/server/registry/offers.js
@@@ -123,6 +123,12 @@ async function get(db, id) {
return row ? JSON.parse(row.doc) : null;
}
++/**
++ * A person's own machine is never public (contracts 0.161.0): user-owned offers, and their owner, are left out of the
++ * public list and answer 404 there; placement reads them from the internal list.
++ */
++const isPublic = (doc) => doc.trust !== 'user-owned';
++
/** The public form of an offer: capacity and harness connection address are private. */
function publicDoc({ capacity, ...doc }) {
if (doc.kind !== 'harness' || !doc.detail) return doc;
@@@ -147,28 +153,29 @@ function validatePublicDoc(doc) {
return validate(CONTRACT, { ...doc, detail: { ...doc.detail, address: { kind: 'cli', target: 'redacted' } } });
}
--function routers({ guard }) {
-- const listed = async (req, shape) => {
++function routers({ guard, readGuard = guard }) {
++ const listed = async (req, shape, keep = () => true) => {
const filters = filtersOf(req.query);
-- const docs = (await list(req.app.locals.db, filters)).map(shape);
++ const docs = (await list(req.app.locals.db, filters)).filter(keep).map(shape);
return { offers: docs, generated_at: new Date().toISOString(), filters, count: docs.length };
};
const unknown = (res, id) => require('openvibe-contracts').http.sendProblem(res, 404, 'registry.unknown_offer', { detail: `no offer ${id}` });
const pub = express.Router();
const open = (res, maxAge) => res.set('Cache-Control', maxAge ? `public, max-age=${maxAge}` : 'no-store')
.set('Access-Control-Allow-Origin', '*').set('Timing-Allow-Origin', '*');
-- pub.get('/', async (req, res) => open(res, 60).json(await listed(req, publicDoc)));
++ pub.get('/', async (req, res) => open(res, 60).json(await listed(req, publicDoc, isPublic)));
pub.get('/:offer_id', async (req, res) => {
const doc = await get(req.app.locals.db, req.params.offer_id);
-- if (!doc) return unknown(open(res, 0), req.params.offer_id);
++ if (!doc || !isPublic(doc)) return unknown(open(res, 0), req.params.offer_id);
open(res, 60).json(publicDoc(doc));
});
// A beacon a browser times to estimate its distance from an offer (openvibe-sdk/geo), as nodes.js answers one.
pub.get('/:offer_id/beacon', (req, res) => open(res, 0).status(204).end());
const internal = express.Router();
-- // The full docs, capacity included, for placement.plan() consumers: the same guard as the report.
-- internal.get('/', guard, async (req, res) => res.set('Cache-Control', 'no-store').json(await listed(req, (d) => d)));
-- internal.get('/:offer_id', guard, async (req, res) => {
++ // The full docs, capacity and user-owned offers included, for placement.plan() consumers: the report's guard, or
++ // network.registry.read for a placer that reads and never reports (Run).
++ internal.get('/', readGuard, async (req, res) => res.set('Cache-Control', 'no-store').json(await listed(req, (d) => d)));
++ internal.get('/:offer_id', readGuard, async (req, res) => {
const doc = await get(req.app.locals.db, req.params.offer_id);
if (!doc) return unknown(res.set('Cache-Control', 'no-store'), req.params.offer_id);
res.set('Cache-Control', 'no-store').json(doc);
ddiff --git a/test/user-owned-offers.test.js b/test/user-owned-offers.test.js
nnew file mode 100644
iindex 00000000..c8ed23bd
---- /dev/null
++++ b/test/user-owned-offers.test.js
@@@ -0,0 +1,82 @@
++'use strict';
++// A person's own machine in the resource registry (plan T14/T16, contracts 0.161.0): Bot reports a user-owned worker
++// offer that names its owner; the public offers list and the public single read leave it out (404), while placement
++// reads it whole from /internal/resources with network.registry.read (Run) or network.resource.report (a reporter).
++// The grants that wire the chain exist: Bot reports, Run reads, Codes submits, reads, streams and cancels Run jobs.
++// node test/user-owned-offers.test.js
++const assert = require('assert');
++const crypto = require('crypto');
++const http = require('http');
++const express = require('express');
++const { getDb } = require('../server/db/database');
++const principals = require('../server/identity/principals');
++const offers = require('../server/registry/offers');
++
++(async () => {
++ const log = console.log; console.log = () => {};
++ const db = getDb();
++ console.log = log;
++ for (const id of ['bot', 'run', 'tools']) {
++ await db.prepare(`INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES (?, ?, ?, '[]', 1)
++ ON CONFLICT (client_id) DO UPDATE SET client_secret = EXCLUDED.client_secret`).run(id, `${id}-secret`, id);
++ }
++ await principals.ensureSchema(db); // the default grants of the clients just added
++ const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
++ const app = express();
++ app.use(express.urlencoded({ extended: true }));
++ app.locals.db = db;
++ app.locals.config = { jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' } };
++ app.locals.privateKey = keys.privateKey;
++ app.locals.publicKey = keys.publicKey;
++ app.use('/oauth', require('../server/auth/oauth-routes'));
++ const r = offers.routers({
++ guard: principals.guard('network.resource.report'),
++ readGuard: principals.guardAny(['network.resource.report', 'network.registry.read']),
++ });
++ app.use('/api/v1/offers', r.pub);
++ app.use('/internal/resources', r.internal);
++ const server = http.createServer(app);
++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const token = async (id) => (await (await fetch(`${base}/oauth/token`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'client_credentials', client_id: id, client_secret: `${id}-secret`, audience: 'openvibe.network' }) })).json()).access_token;
++ const get = (path, tok) => fetch(`${base}${path}`, { headers: tok ? { authorization: `Bearer ${tok}` } : {} }).then(async (x) => ({ status: x.status, body: await x.json().catch(() => null) }));
++ const OWNER = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A1';
++ const mine = {
++ offer_id: 'node:dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2D1', kind: 'node', node_id: 'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2D1', provider: 'self',
++ region: 'home', cell: 'wnam-1', trust: 'user-owned', owner: OWNER, capabilities: ['worker:code'],
++ health: { status: 'up' }, pricing: { model: 'free-allowance', marginal_usd_per_unit: 0 }, updated_at: '2026-10-14T12:00:00Z',
++ };
++ const ours = { ...mine, offer_id: 'node:first-party-1', node_id: 'first-party-1', provider: 'ovh', region: 'us-west', trust: 'first-party', owner: undefined };
++ try {
++ const bot = await token('bot');
++ const run = await token('run');
++ const tools = await token('tools');
++ assert.ok(bot && run && tools, `the three services mint tokens: ${[bot, run, tools].map(Boolean)}`);
++ const report = await fetch(`${base}/internal/resources/report`, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${bot}` }, body: JSON.stringify({ source: 'bot', offers: [mine, ours] }) });
++ assert.strictEqual(report.status, 200, 'Bot holds network.resource.report and its user-owned offer validates with its owner');
++
++ const pub = await get('/api/v1/offers?kind=node');
++ assert.deepStrictEqual(pub.body.offers.map((o) => o.offer_id), ['node:first-party-1'], 'the public list leaves the person’s machine out');
++ assert.strictEqual(JSON.stringify(pub.body).includes(OWNER), false, 'and never shows its owner');
++ assert.strictEqual((await get(`/api/v1/offers/${encodeURIComponent(mine.offer_id)}`)).status, 404, 'a public single read is 404');
++ assert.strictEqual((await get('/api/v1/offers/node:first-party-1')).status, 200);
++
++ const internal = await get('/internal/resources?kind=node', run);
++ assert.strictEqual(internal.status, 200, 'Run reads the internal list with network.registry.read');
++ const got = internal.body.offers.find((o) => o.offer_id === mine.offer_id);
++ assert.strictEqual(got && got.owner, OWNER, 'with the owner, for placement');
++ assert.strictEqual((await get('/internal/resources?kind=node', bot)).status, 200, 'the reporter still reads it');
++ assert.strictEqual((await get('/internal/resources?kind=node', tools)).status, 403, 'a service with neither capability does not');
++ assert.strictEqual((await get('/internal/resources?kind=node')).status, 403, 'nor does anyone without a token');
++ const runReport = await fetch(`${base}/internal/resources/report`, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${run}` }, body: JSON.stringify({ source: 'run', offers: [] }) });
++ assert.strictEqual(runReport.status, 403, 'Run reads but never reports');
++
++ const has = (svc, cap, aud) => principals.DEFAULT_GRANTS.some(([s, c, a]) => s === svc && c === cap && a === aud);
++ assert.ok(has('bot', 'network.resource.report', 'openvibe.network'));
++ assert.ok(has('run', 'network.registry.read', 'openvibe.network'));
++ for (const cap of ['run.job.submit', 'run.job.read', 'run.job.stream', 'run.job.cancel']) assert.ok(has('codes', cap, 'openvibe.run'), cap);
++ console.log('user-owned-offers: ok');
++ } finally {
++ server.close();
++ }
++})().catch((err) => { console.error(err); process.exit(1); });