openvibers/openvibe.network Commits
Create the orchestrator's service principal for the AI editor reviews (#618)
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.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",@@@ -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",@@@ -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@@@ -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', []],nnew file mode 100644@@@ -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); });
@@ -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.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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.124.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -2303,9 +2303,9 @@
@@ -2303,9 +2303,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"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",
"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.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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.124.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -176,6 +176,10 @@ async function seedDb(db, { log = console } = {}) {
@@ -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
// 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.
// 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'] },
{ 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,
// 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
// 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
// ceiling. The Chrome id is fixed by browser/manifest.json's key; the Firefox hash is SHA-1 of
@@ -344,6 +344,13 @@ const DEFAULT_GRANTS = [
@@ -344,6 +344,13 @@ const DEFAULT_GRANTS = [
...['chat', 'connect', 'deals', 'work', 'sources', 'design', 'domains', 'media']
...['chat', 'connect', 'deals', 'work', 'sources', 'design', 'domains', 'media']
.filter((svc) => capabilities.get(`${svc}.config.manage`))
.filter((svc) => capabilities.get(`${svc}.config.manage`))
.map((svc) => ['network', `${svc}.config.manage`, `openvibe.${svc}`, []]),
.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
// 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.
// /api/v1/providers), as Network, naming the owner in X-OV-Subject.
['network', 'ai.provider.manage', 'openvibe.ai', []],
['network', 'ai.provider.manage', 'openvibe.ai', []],
new file mode 100644
new file mode 100644
@@ -0,0 +1,92 @@
@@ -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); });