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)
Changes
@@@ -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",@@@ -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",@@@ -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 };@@@ -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);}@@@ -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);nnew file mode 100644@@@ -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); });
@@ -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.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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.3",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -1320,9 +1320,9 @@
@@ -1320,9 +1320,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"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",
"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.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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.3",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -262,6 +262,13 @@ const DEFAULT_GRANTS = [
@@ -262,6 +262,13 @@ const DEFAULT_GRANTS = [
// (robots.agent_control); its gate, limits and e-stop still judge every step.
// (robots.agent_control); its gate, limits and e-stop still judge every step.
['actor', 'bot.robot.read', 'openvibe.bot', []],
['actor', 'bot.robot.read', 'openvibe.bot', []],
['actor', 'bot.robot.control', '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.
// 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.
// Other services get this row only when they ship a caller of GET /internal/projects/:project_id.
['host', 'network.project.read', SELF_AUDIENCE, []],
['host', 'network.project.read', SELF_AUDIENCE, []],
@@ -591,4 +598,18 @@ function guard(capability, { ownApp, namespace } = {}) {
@@ -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 };
@@ -493,7 +493,11 @@ const notifyAccountData = async (userId, n) => await notificationService.create(
@@ -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
// 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.
// 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('/api/v1/offers', offerRouters.pub);
app.use('/internal/resources', offerRouters.internal);
app.use('/internal/resources', offerRouters.internal);
}
}
@@ -123,6 +123,12 @@ async function get(db, id) {
@@ -123,6 +123,12 @@ async function get(db, id) {
return row ? JSON.parse(row.doc) : null;
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. */
/** The public form of an offer: capacity and harness connection address are private. */
function publicDoc({ capacity, ...doc }) {
function publicDoc({ capacity, ...doc }) {
if (doc.kind !== 'harness' || !doc.detail) return doc;
if (doc.kind !== 'harness' || !doc.detail) return doc;
@@ -147,28 +153,29 @@ function validatePublicDoc(doc) {
@@ -147,28 +153,29 @@ function validatePublicDoc(doc) {
return validate(CONTRACT, { ...doc, detail: { ...doc.detail, address: { kind: 'cli', target: 'redacted' } } });
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 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 };
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 unknown = (res, id) => require('openvibe-contracts').http.sendProblem(res, 404, 'registry.unknown_offer', { detail: `no offer ${id}` });
const pub = express.Router();
const pub = express.Router();
const open = (res, maxAge) => res.set('Cache-Control', maxAge ? `public, max-age=${maxAge}` : 'no-store')
const open = (res, maxAge) => res.set('Cache-Control', maxAge ? `public, max-age=${maxAge}` : 'no-store')
.set('Access-Control-Allow-Origin', '*').set('Timing-Allow-Origin', '*');
.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) => {
pub.get('/:offer_id', async (req, res) => {
const doc = await get(req.app.locals.db, req.params.offer_id);
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));
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.
// 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());
pub.get('/:offer_id/beacon', (req, res) => open(res, 0).status(204).end());
const internal = express.Router();
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);
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);
if (!doc) return unknown(res.set('Cache-Control', 'no-store'), req.params.offer_id);
res.set('Cache-Control', 'no-store').json(doc);
res.set('Cache-Control', 'no-store').json(doc);
new file mode 100644
new file mode 100644
@@ -0,0 +1,82 @@
@@ -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); });