openvibers/openvibe.network Commits

The sandbox worker's own service client and live.agent_sandbox.register grant (#616)

OP OpenVibers committed 6 days ago · d59a0ff1f4 · 7 files, +101 −7

* The sandbox worker's own Network service client and grant

Seed codes-sandbox as a service principal beside the other seeded service clients:
no redirect URIs, first-party, a secret generated and stored in oauth_clients and
never logged. Its one default grant is live.agent_sandbox.register for the audience
openvibe.live, unscoped: the worker checks in for the person whose stream it works,
never for another. It is its own principal, not Codes, so Live can tell the worker
from a person's Codes session.

Pin openvibe-contracts v0.314.0, which names the capability, and test the seed, the
grant row, the client_credentials token for openvibe.live and the refusal of any
other audience.

* Merge origin/main into feat/sandbox-worker-client

Take main's pin bump to Contracts v0.312.0 and keep this branch's v0.314.0, the
release that names live.agent_sandbox.register; README and STATUS follow.

Changes

ddiff --git a/README.md b/README.md
iindex 2ee89f5e..afaff184 100644
---- a/README.md
++++ b/README.md
@@@ -1026,7 +1026,7 @@ Every change also sends the person a Network notification, so a swap nobody aske
MIT (`license` in [package.json](package.json)).
<!-- versions:start -->
--- openvibe-contracts: v0.312.0
++- openvibe-contracts: v0.314.0
- openvibe-sdk: v0.93.0
- openvibe-shared: v4.124.0
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex 8fc0d495..6d3e2223 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -6,7 +6,7 @@
"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"port": 4000,
"domain": "openvibe.network (with auth., api., admin., my. and themes.openvibe.network in its manifest)",
-- "contracts": "openvibe-contracts v0.312.0",
++ "contracts": "openvibe-contracts v0.314.0",
"sdk": "openvibe-sdk v0.93.0",
"shared": "openvibe-shared v4.124.0",
"capabilities": [
ddiff --git a/package-lock.json b/package-lock.json
iindex 8c3ef78f..5b7f1b2f 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.312.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.314.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.93.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.124.0",
"pg": "^8.23.0",
@@@ -2303,9 +2303,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.312.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.312.0",
-- "integrity": "sha512-2GsD53zuIedL3eW2LBlYQ68ODTHzoZJHrAmVnZCpiL7ZWnAXGwPW9dpQagLI9Zdkh3LQuuU5ae1YJI0K+acXbA==",
++ "version": "0.314.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.314.0",
++ "integrity": "sha512-sq5idLBEw+fWc4J63QvmZocf6dxq91qrtsUFzU362uKJ2hpXFQFtos2gGx1oeAyqmKL/5YfEikc/uKNY0v/x5w==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex 7dc49122..de453b74 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.312.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.314.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.93.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.124.0",
"pg": "^8.23.0",
ddiff --git a/server/db/database.js b/server/db/database.js
iindex f96d0db7..4a78c125 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -110,6 +110,11 @@ async function seedDb(db, { log = console } = {}) {
{ client_id: 'coupons', name: 'OpenVibe.Coupons', redirect_uris: ['https://openvibe.coupons/auth/callback'] },
{ client_id: 'host', name: 'OpenVibe.Host', redirect_uris: ['https://openvibe.host/auth/callback'] },
{ client_id: 'codes', name: 'OpenVibe.Codes', redirect_uris: ['https://openvibe.codes/auth/callback'] },
++ // The chat plays sandbox worker (Agent Streams S6): a service principal of its own, so the worker that
++ // runs as its own user on Codes' host is not Codes' session and Live can tell them apart. It has no
++ // redirect URIs (client_credentials only); its grant is in server/identity/principals.js and its secret
++ // stays in oauth_clients, from where the owner's host install reads it (the PR says where).
++ { client_id: 'codes-sandbox', name: 'OpenVibe.Codes sandbox worker', redirect_uris: [] },
// The developer console (moved from openvibe.codes on 2026-10-08); the client already exists as Services' principal.
{ client_id: 'services', name: 'OpenVibe.Services', redirect_uris: ['https://openvibe.services/auth/callback'] },
{ client_id: 'billing', name: 'OpenVibe.Billing', redirect_uris: ['https://billing.openvibe.network/auth/callback'] },
ddiff --git a/server/identity/principals.js b/server/identity/principals.js
iindex 1296781b..fdcd86f6 100644
---- a/server/identity/principals.js
++++ b/server/identity/principals.js
@@@ -839,6 +839,12 @@ const DEFAULT_GRANTS = [
// service the way in, not the queue.
['codes', 'chat.play_queue.read', 'openvibe.chat', []],
['codes', 'chat.play_queue.write', 'openvibe.chat', []],
++ // The Agent Streams plan (S6, Contracts 0.314.0): the chat plays sandbox worker checks in with Live
++ // (live.agent_sandbox.register, the audience openvibe.live) for the person whose stream it works. It is its own
++ // service principal (`codes-sandbox`), never Codes: the worker runs as its own user with its own Network client,
++ // and Live must be able to tell it from a person's Codes session. It acts for the person named in X-OV-Subject,
++ // never for anyone else, so the grant is unscoped and the audience is Live's alone.
++ ['codes-sandbox', 'live.agent_sandbox.register', 'openvibe.live', []],
// The Agent Streams plan (S3, Contracts 0.300.0): Codes, Actor and Live write the report of an agent session on
// OpenVibe.Report (report.session.write, the audience openvibe.report). They act for the person and for nobody
// else, so the grant is unscoped and the audience is Report's alone. A desk session on a person's own machine
ddiff --git a/test/sandbox-worker-client.test.js b/test/sandbox-worker-client.test.js
nnew file mode 100644
iindex 00000000..a96e2df1
---- /dev/null
++++ b/test/sandbox-worker-client.test.js
@@@ -0,0 +1,83 @@
++'use strict';
++/**
++ * The chat plays sandbox worker's own Network service client (Agent Streams S6).
++ *
++ * The worker is not Codes: it runs as its own unprivileged user with its own OAuth client, so a token it holds names
++ * `codes-sandbox` and Live can tell it from a person's Codes session. The client is seeded with the other service
++ * clients (server/db/database.js), has no redirect URIs (client_credentials only), and its one grant is
++ * live.agent_sandbox.register for the audience openvibe.live. Its secret is never printed here: the owner reads it
++ * from Network's admin (the `oauth_clients` row) into the worker's network-client-secret credential.
++ *
++ * node test/sandbox-worker-client.test.js
++ */
++const assert = require('assert');
++const crypto = require('crypto');
++const contracts = require('openvibe-contracts');
++const { getDb, seedDb } = require('../server/db/database');
++
++const CLIENT = 'codes-sandbox';
++const CAP = 'live.agent_sandbox.register';
++const AUDIENCE = 'openvibe.live';
++const ISSUER = 'https://openvibe.network';
++
++(async () => {
++ const principals = require('../server/identity/principals');
++ const db = getDb();
++ const quiet = console.log;
++ console.log = () => {};
++ await seedDb(db, { log: { log() {}, warn() {}, error() {} } });
++ console.log = quiet;
++
++ const client = await db.prepare('SELECT client_id, name, redirect_uris, client_secret, is_first_party FROM oauth_clients WHERE client_id = ?').get(CLIENT);
++ assert.ok(client, 'the seed creates the worker as its own service client');
++ assert.strictEqual(client.redirect_uris, '[]', 'it has no redirect URIs: client_credentials only');
++ assert.strictEqual(Number(client.is_first_party), 1, 'it is first-party');
++ assert.ok(client.client_secret && String(client.client_secret).length >= 20, 'the seed gives it a secret (stored, never logged)');
++
++ const grants = await db.prepare('SELECT capability, audience, namespaces, granted_by, revoked_at FROM principal_grants WHERE client_id = ?').all(CLIENT);
++ assert.deepStrictEqual(grants.map((g) => g.capability), [CAP], 'its only grant is the sandbox registration');
++ const row = grants[0];
++ assert.strictEqual(row.audience, AUDIENCE, 'the grant is for Live alone');
++ assert.deepStrictEqual(JSON.parse(row.namespaces || '[]'), [], 'it is unscoped');
++ assert.strictEqual(row.granted_by, 'default');
++ assert.strictEqual(row.revoked_at, null);
++
++ // The row is a default grant, held by no other principal.
++ assert.deepStrictEqual(principals.DEFAULT_GRANTS.find((g) => g[0] === CLIENT && g[1] === CAP && g[2] === AUDIENCE),
++ [CLIENT, CAP, AUDIENCE, []]);
++ assert.ok(!principals.DEFAULT_GRANTS.some((g) => g[1] === CAP && g[0] !== CLIENT), 'no other service holds live.agent_sandbox.register');
++ assert.ok(!principals.DEFAULT_GRANTS.some((g) => g[1] === CAP && g[2] !== AUDIENCE), 'the capability is granted for Live and no other audience');
++
++ // The pinned Contracts release names the capability, and gives it Live as its owner.
++ const cap = contracts.capabilities.get(CAP);
++ assert.ok(cap, 'the pinned contracts know ' + CAP);
++ assert.strictEqual(cap.owner, 'live');
++ assert.ok(cap.implementedBy.includes('POST /api/streams/agent/:id/plays/worker'));
++
++ // A client_credentials token for openvibe.live carries exactly the registration capability.
++ const keys = crypto.generateKeyPairSync('rsa', {
++ modulusLength: 2048,
++ publicKeyEncoding: { type: 'spki', format: 'pem' },
++ privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
++ });
++ const issued = await principals.issueToken(db, {
++ clientId: CLIENT, clientSecret: client.client_secret, audience: AUDIENCE, privateKey: keys.privateKey, issuer: ISSUER,
++ });
++ assert.strictEqual(issued.status, 200, JSON.stringify(issued.body));
++ const claims = contracts.serviceAuth.verifyServiceToken(issued.body.access_token, {
++ publicKey: keys.publicKey, issuer: ISSUER, audience: AUDIENCE,
++ });
++ assert.ok(claims.ok, claims.reason);
++ assert.deepStrictEqual(claims.claims.cap, [CAP], 'the token carries the registration capability and nothing else');
++ assert.strictEqual(claims.claims.sub, `svc:${CLIENT}`, 'the token names the worker, not Codes');
++ assert.strictEqual(claims.claims.on_behalf_of, undefined, 'the person is named by X-OV-Subject on the request, not minted into the token');
++
++ // A token for another audience is refused: this client has nothing there.
++ const elsewhere = await principals.issueToken(db, {
++ clientId: CLIENT, clientSecret: client.client_secret, audience: 'openvibe.chat', privateKey: keys.privateKey, issuer: ISSUER,
++ });
++ assert.strictEqual(elsewhere.status, 400, 'no grants for openvibe.chat');
++ assert.match(String(elsewhere.body.error_description), /no grants/);
++
++ console.log('sandbox worker client: ' + CAP + ' for ' + AUDIENCE + ', client_credentials only');
++})().catch((err) => { console.error(err); process.exit(1); });