openvibers/openvibe.network Commits

Create the orchestrator's service principal for the AI editor reviews (#618)

OP OpenVibers committed 6 days ago · 5fa73cfd35 · 5 files, +108 −5

Contracts 0.315.0 defines blog.review.ai_editor, news.review.ai_editor and wiki.review.ai_editor. Seed an orchestrator client (client_credentials only, no redirect URIs) with exactly those three grants, each for its own service's audience and never a wildcard, so the agent runs coordinator can record the editorial queue's decision as itself. Pin openvibe-contracts 0.315.0.

Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex 5b7f1b2f..14082a3a 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.314.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.315.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.314.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.314.0",
-- "integrity": "sha512-sq5idLBEw+fWc4J63QvmZocf6dxq91qrtsUFzU362uKJ2hpXFQFtos2gGx1oeAyqmKL/5YfEikc/uKNY0v/x5w==",
++ "version": "0.315.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.315.0",
++ "integrity": "sha512-m1bWT4/sF6Hc7lia6FjAR+880r9SBRhO/v819Lr5ldxlSkFYiKMXTtt7MDbJ4FCgQ7Dkt+2OJaKanlvk2vNPlg==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex de453b74..75a8119e 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.314.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.315.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 4a78c125..2f8d89fc 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -176,6 +176,10 @@ async function seedDb(db, { log = console } = {}) {
// OpenVibe Git: Forgejo at openvibe.codes/git (OpenVibe.Codes deploy/forgejo, WS-X12), signing in through OpenID
// Connect; its callback is Forgejo's /user/oauth2/<source name>/callback. git.openvibe.codes once that name exists.
{ client_id: 'forgejo', name: 'OpenVibe Git', redirect_uris: ['https://openvibe.codes/git/user/oauth2/OpenVibe/callback', 'https://git.openvibe.codes/user/oauth2/OpenVibe/callback'] },
++ // The orchestrator (2026-11-07): the agent runs coordinator records the editorial queue's AI editor decision
++ // on Blog, News and Wiki as itself, never for a person. It has no redirect URIs (client_credentials only);
++ // its three grants are in server/identity/principals.js, exactly one per service, never a wildcard.
++ { client_id: 'orchestrator', name: 'OpenVibe orchestrator', redirect_uris: [] },
// The Dock in the browser extension (2026-10-30): a public first-party client with no secret,
// PKCE S256 required, the Chrome/Firefox extension redirect URIs exactly, and a read-only scope
// ceiling. The Chrome id is fixed by browser/manifest.json's key; the Firefox hash is SHA-1 of
ddiff --git a/server/identity/principals.js b/server/identity/principals.js
iindex fdcd86f6..095dffc3 100644
---- a/server/identity/principals.js
++++ b/server/identity/principals.js
@@@ -344,6 +344,13 @@ const DEFAULT_GRANTS = [
...['chat', 'connect', 'deals', 'work', 'sources', 'design', 'domains', 'media']
.filter((svc) => capabilities.get(`${svc}.config.manage`))
.map((svc) => ['network', `${svc}.config.manage`, `openvibe.${svc}`, []]),
++ // The editorial queue's AI editor (Contracts 0.315.0, 2026-11-07): the orchestrator's service principal records
++ // each service's decision on the AI-written text through that service's reviews route, as itself (a svc: principal,
++ // no X-OV-Subject). Exactly the three capabilities, each for its own service's audience: never a wildcard, never
++ // another service's. New rows, so no RESTORED_GRANTS or CHANGED_DEFAULT_NAMESPACES entry is needed.
++ ['orchestrator', 'blog.review.ai_editor', 'openvibe.blog', []],
++ ['orchestrator', 'news.review.ai_editor', 'openvibe.news', []],
++ ['orchestrator', 'wiki.review.ai_editor', 'openvibe.wiki', []],
// The owner's AI tab lists and edits AI's provider registry through AI's own admin API (GET/POST/PATCH
// /api/v1/providers), as Network, naming the owner in X-OV-Subject.
['network', 'ai.provider.manage', 'openvibe.ai', []],
ddiff --git a/test/orchestrator-review-client.test.js b/test/orchestrator-review-client.test.js
nnew file mode 100644
iindex 00000000..ac0b0284
---- /dev/null
++++ b/test/orchestrator-review-client.test.js
@@@ -0,0 +1,92 @@
++'use strict';
++/**
++ * The orchestrator's own Network service client (2026-11-07).
++ *
++ * The agent runs coordinator records the editorial queue's AI editor decision on Blog, News and Wiki. It does so as
++ * itself — a svc: principal — never for a person: a token it holds names `orchestrator` and carries exactly that
++ * service's review capability. The client is seeded with the other service clients (server/db/database.js), has no
++ * redirect URIs (client_credentials only), and its three grants are in server/identity/principals.js: exactly one per
++ * service, each for its own service's audience, never a wildcard.
++ *
++ * node test/orchestrator-review-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 = 'orchestrator';
++const GRANTS = [
++ ['blog.review.ai_editor', 'openvibe.blog', 'POST /api/v1/posts/:id/reviews'],
++ ['news.review.ai_editor', 'openvibe.news', 'POST /api/v1/stories/:id/reviews'],
++ ['wiki.review.ai_editor', 'openvibe.wiki', 'POST /api/v1/pages/:id/revisions/:n/review'],
++];
++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 orchestrator as its own service client');
++ assert.strictEqual(client.name, 'OpenVibe orchestrator');
++ 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 rows = await db.prepare('SELECT capability, audience, namespaces, granted_by, revoked_at FROM principal_grants WHERE client_id = ? ORDER BY capability').all(CLIENT);
++ assert.deepStrictEqual(rows.map((r) => [r.capability, r.audience]), GRANTS.map(([cap, aud]) => [cap, aud]), 'exactly the three review capabilities, each for its own service');
++ for (const r of rows) {
++ assert.deepStrictEqual(JSON.parse(r.namespaces || '[]'), [], 'it is unscoped');
++ assert.strictEqual(r.granted_by, 'default');
++ assert.strictEqual(r.revoked_at, null);
++ }
++
++ // The rows are default grants, each held by no other principal and for no other audience.
++ for (const [cap, aud] of GRANTS) {
++ assert.deepStrictEqual(principals.DEFAULT_GRANTS.find((g) => g[0] === CLIENT && g[1] === cap && g[2] === aud), [CLIENT, cap, aud, []]);
++ assert.ok(!principals.DEFAULT_GRANTS.some((g) => g[1] === cap && g[0] !== CLIENT), 'no other service holds ' + cap);
++ assert.ok(!principals.DEFAULT_GRANTS.some((g) => g[1] === cap && g[2] !== aud), cap + ' is granted for ' + aud + ' and no other audience');
++ }
++
++ // The pinned Contracts release names each capability, gives it the service as its owner, and names the route.
++ for (const [cap, aud, route] of GRANTS) {
++ const c = contracts.capabilities.get(cap);
++ assert.ok(c, 'the pinned contracts know ' + cap);
++ assert.strictEqual(c.owner, aud.replace('openvibe.', ''));
++ assert.ok(c.implementedBy.includes(route), cap + ' names ' + route);
++ }
++
++ // A client_credentials token for each audience carries exactly that service's review capability.
++ const keys = crypto.generateKeyPairSync('rsa', {
++ modulusLength: 2048,
++ publicKeyEncoding: { type: 'spki', format: 'pem' },
++ privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
++ });
++ for (const [cap, aud] of GRANTS) {
++ const issued = await principals.issueToken(db, {
++ clientId: CLIENT, clientSecret: client.client_secret, audience: aud, 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: aud,
++ });
++ assert.ok(claims.ok, claims.reason);
++ assert.deepStrictEqual(claims.claims.cap, [cap], 'the token carries ' + cap + ' and nothing else');
++ assert.strictEqual(claims.claims.sub, `svc:${CLIENT}`, 'the token names the orchestrator, never a person');
++ assert.strictEqual(claims.claims.on_behalf_of, undefined, 'a review is never minted for a person');
++ }
++
++ // A token for an audience it has no grant for is refused.
++ 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('orchestrator review client: ' + GRANTS.map(([cap, aud]) => `${cap} for ${aud}`).join(', '));
++})().catch((err) => { console.error(err); process.exit(1); });